@lotics/ui 26.4.1 → 27.7.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/MIGRATION.md +70 -0
- package/docs/catalog.md +64 -25
- package/docs/composition.md +17 -15
- package/docs/templates.md +30 -20
- package/examples/tpl_item_list.tsx +5 -6
- package/examples/tpl_record.tsx +159 -15
- package/examples/tpl_task_board.tsx +1 -2
- package/package.json +1 -2
- package/src/filter_chip.tsx +2 -2
- package/src/ledger.tsx +164 -27
- package/src/option_list.tsx +8 -5
- package/src/reference_field.tsx +25 -11
- package/src/select.tsx +21 -6
- package/src/summary_line.tsx +14 -0
- package/src/text_input_field.tsx +8 -1
- package/src/text_link.tsx +9 -5
- package/src/text_button.tsx +0 -139
package/src/reference_field.tsx
CHANGED
|
@@ -220,6 +220,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
220
220
|
triggerRef={anchor}
|
|
221
221
|
side="bottom"
|
|
222
222
|
align="start"
|
|
223
|
+
inheritTriggerWidth
|
|
223
224
|
>
|
|
224
225
|
{/* THE KIT'S FIELD, not a lookalike. This was hand-rolled — a Pressable
|
|
225
226
|
wearing copies of the field's border, radius, height and hover — and it
|
|
@@ -261,12 +262,24 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
261
262
|
rather than a hand-picked font weight; the facts are `DetailRow`s, which
|
|
262
263
|
is what label-beside-value IS everywhere else on this page; and the
|
|
263
264
|
verbs sit in the popover's own pinned footer rather than floating after
|
|
264
|
-
the last fact.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
265
|
+
the last fact.
|
|
266
|
+
|
|
267
|
+
WIDTH COMES FROM THE FIELD (`inheritTriggerWidth`), not a constant. A
|
|
268
|
+
fixed 320 was "every peek in an app is the same object", and the object
|
|
269
|
+
it was actually the same as was nothing on screen: measured on a record
|
|
270
|
+
page, a 558px field opened a 320px panel left-aligned under it, stopping
|
|
271
|
+
238px short of the edge the reader's eye had just travelled to. A
|
|
272
|
+
popover narrower than the control that opened it reads as belonging to
|
|
273
|
+
something else. `InlineSelect` — the same shape, an inline field opening
|
|
274
|
+
a `triggerRef` popover — already inherits its field's width, so the two
|
|
275
|
+
were following different rules for one job.
|
|
276
|
+
|
|
277
|
+
The prop is floored at `MIN_CONTROL_WIDTH`, so a reference in a dense
|
|
278
|
+
grid column still opens something readable rather than a 90px sliver.
|
|
279
|
+
Between that floor and `labelWidth + MIN_CONTROL_WIDTH + 24` a
|
|
280
|
+
`DetailTable` STACKS its columns, which is the right answer at that
|
|
281
|
+
width, not a failure — 88 keeps the side-by-side grammar wherever there
|
|
282
|
+
is room for it, which is the whole point of a glance. */}
|
|
270
283
|
{/* `PopoverContent` PARTITIONS its children: a `PopoverHeader` sits above
|
|
271
284
|
the scroller, a `PopoverFooter` is pinned below it, and everything else
|
|
272
285
|
scrolls between them. This component used none of that — it disabled
|
|
@@ -274,11 +287,12 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
274
287
|
is invisible at three facts and fatal at eleven: the panel ran past the
|
|
275
288
|
viewport with no way to scroll, carrying its own Save button off-screen
|
|
276
289
|
with it. The three-fact fixture is what hid it. */}
|
|
277
|
-
{/* `width`
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
290
|
+
{/* No `width` here at all now: a fixed panel width is applied LAST in the
|
|
291
|
+
style chain, so it beats `inheritTriggerWidth` — passing both would have
|
|
292
|
+
silently kept the 320. `maxHeight` stays on the body, which is the band
|
|
293
|
+
that scrolls; the header keeps its own height so a wrapped two-line name
|
|
294
|
+
is never what scrolls out of view. */}
|
|
295
|
+
<PopoverContent style={{ maxHeight: 420 }}>
|
|
282
296
|
<PopoverHeader>
|
|
283
297
|
<DialogSectionHeadingTitle description={code}>{name}</DialogSectionHeadingTitle>
|
|
284
298
|
</PopoverHeader>
|
package/src/select.tsx
CHANGED
|
@@ -226,12 +226,27 @@ function SelectTrigger<T extends string>({
|
|
|
226
226
|
<FocusRingPressable
|
|
227
227
|
ref={ref}
|
|
228
228
|
testID={testID}
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
229
|
+
// `combobox`, NOT `button`, and the reason is structural rather than semantic.
|
|
230
|
+
// A role is what react-native-web picks the ELEMENT from, and `button` gave a real
|
|
231
|
+
// <button> — whose content model forbids interactive descendants. The sanctioned
|
|
232
|
+
// chip-box (`renderSelected` → `<Chip onDismiss>`, the reason there is no separate
|
|
233
|
+
// TagInput) puts a remove button inside the trigger, so the trigger rendered a
|
|
234
|
+
// <button> inside a <button>: invalid HTML, a React error on every mount, and a
|
|
235
|
+
// removal control of doubtful reachability. `combobox` renders a <div>, which may
|
|
236
|
+
// legally contain buttons, and it is the role the APG pattern this control already
|
|
237
|
+
// follows actually calls for (docs/accessibility.md: a popup opens on Enter/Space,
|
|
238
|
+
// never on Tab arrival). Verified on the chip demo: tab-focusable (tabindex 0),
|
|
239
|
+
// Enter opens, pointer opens, Escape closes, and the ✕ removes without opening.
|
|
240
|
+
//
|
|
241
|
+
// Without any role this Pressable would render an unfocusable <div> — out of the
|
|
242
|
+
// tab order, no keyboard activation. `aria-expanded` announces open/closed. The
|
|
243
|
+
// W3C props, NOT `accessibilityState` — this build drops the latter silently.
|
|
244
|
+
//
|
|
245
|
+
// When `searchable`, OptionList's filter input is ALSO a combobox (it owns
|
|
246
|
+
// aria-controls + aria-activedescendant, the editable half of the pattern). Two
|
|
247
|
+
// combobox-shaped controls across two focus events is redundant, not wrong — and
|
|
248
|
+
// strictly better than the invalid nesting it replaced.
|
|
249
|
+
accessibilityRole="combobox"
|
|
235
250
|
accessibilityLabel={accessibilityLabel}
|
|
236
251
|
aria-expanded={open}
|
|
237
252
|
aria-disabled={disabled}
|
package/src/summary_line.tsx
CHANGED
|
@@ -32,6 +32,20 @@ export interface SummaryLineProps {
|
|
|
32
32
|
* dashboard stat band: that's `KPIStrip` (a boxed metric grid for dashboard
|
|
33
33
|
* pages, left untouched). Here there's no card, no big numbers, no column rules
|
|
34
34
|
* — a small `Metric` value + a muted label, items wrapping, separated by space.
|
|
35
|
+
*
|
|
36
|
+
* **It summarizes a SET, and it goes with the set it summarizes.** Every item must be
|
|
37
|
+
* an aggregate over the rows in view — a count, a sum, a fill — so the strip earns its
|
|
38
|
+
* place by answering something no single row can. Nesting counts as a set: a record
|
|
39
|
+
* that gathers children (a consol of house shipments, an order of lines) may carry one
|
|
40
|
+
* above THAT list.
|
|
41
|
+
*
|
|
42
|
+
* **Never on a record's identity band, reporting that record's own state.** Derived
|
|
43
|
+
* verdicts about one record — "documents incomplete", "2 fields need checking", "no
|
|
44
|
+
* next milestone" — are a checklist, and a record does not carry a checklist
|
|
45
|
+
* (`docs/catalog.md`); work state belongs to `Pipeline`, a field's problem to a
|
|
46
|
+
* `Callout` on the field that can fix it. Such a strip also restates at a distance
|
|
47
|
+
* what the sections below state in place, so it goes stale against its own record and
|
|
48
|
+
* gives the reader two versions of one truth.
|
|
35
49
|
*/
|
|
36
50
|
export function SummaryLine(props: SummaryLineProps) {
|
|
37
51
|
const { items } = props;
|
package/src/text_input_field.tsx
CHANGED
|
@@ -256,7 +256,14 @@ const styles = StyleSheet.create({
|
|
|
256
256
|
// padding stay (they hold the field's size); the horizontal padding goes,
|
|
257
257
|
// because the host already spent CONTROL_TEXT_INSET and two insets stacked
|
|
258
258
|
// shift the text 8px right the moment the field focuses.
|
|
259
|
-
|
|
259
|
+
//
|
|
260
|
+
// `borderWidth: 0`, not merely a transparent colour. A transparent border still
|
|
261
|
+
// OCCUPIES its 1px in the box model, so hiding the colour left the second inset
|
|
262
|
+
// in place at 1/8th the size: measured, the text moved 561 → 562 on focus — the
|
|
263
|
+
// same defect this style exists to fix, at the scale that survives a glance.
|
|
264
|
+
// Safe for height because the box is `border-box`: dropping the border changes
|
|
265
|
+
// the content box, never the field's 40px outer size.
|
|
266
|
+
seamless: { borderColor: "transparent", borderWidth: 0, backgroundColor: "transparent", paddingHorizontal: 0 },
|
|
260
267
|
disabled: {
|
|
261
268
|
color: colors.zinc["400"],
|
|
262
269
|
outlineStyle: "none" as unknown as "solid",
|
package/src/text_link.tsx
CHANGED
|
@@ -20,11 +20,15 @@ export interface TextLinkProps extends TextProps {
|
|
|
20
20
|
* "this opens somewhere else" signal.
|
|
21
21
|
*
|
|
22
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* export was three components — a link, a button, and a passive marker — behind one
|
|
24
|
+
* name, inside `OptionList`, `FilterChip` and others.
|
|
25
|
+
*
|
|
26
|
+
* The act mode briefly became its own surface-less component and is now gone
|
|
27
|
+
* entirely: it made underline mean two things separable only by ink, and it competed
|
|
28
|
+
* with the `Button` colour ladder for the job `muted` already does. **Underlined text
|
|
29
|
+
* is the NAVIGATION affordance; anything that acts carries a control surface** —
|
|
30
|
+
* `Button` in chrome, `InlineButton` on a field. A quiet verb that cannot sit against
|
|
31
|
+
* a text column belongs in chrome, not in a rung of its own.
|
|
28
32
|
*/
|
|
29
33
|
export function TextLink(props: TextLinkProps) {
|
|
30
34
|
const { icon, href, children, color, style, ...textProps } = props;
|
package/src/text_button.tsx
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
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
|
-
});
|