@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.
@@ -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. Width matches `Peek`'s own content width so every peek in
265
- an app is the same object.
266
- `labelWidth` is the one override, and it is not arbitrary: a `DetailTable`
267
- STACKS its columns below `labelWidth + MIN_CONTROL_WIDTH + 24`, so the
268
- page's 150 would flip a 320 popover into stacked form grammar. 88 keeps
269
- the summary side-by-side, which is the whole point of a glance. */}
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` on the PANEL, not `style` `style` reaches only the body, so a
278
- long referenced name in the header stretched the panel past it and the
279
- facts sat in a box wider than themselves. `maxHeight` stays on the
280
- body: that is the band that scrolls. */}
281
- <PopoverContent width={320} style={{ maxHeight: 420 }}>
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
- // Without a role this Pressable renders as an unfocusable <div> on web — the
230
- // trigger drops out of the tab order and Enter/Space can't open it. `button`
231
- // makes it tab-focusable and maps keyboard activation to onPress; `aria-expanded`
232
- // announces open/closed to assistive tech. The W3C props, NOT `accessibilityState`
233
- // this react-native-web build drops the latter silently.
234
- accessibilityRole="button"
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}
@@ -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;
@@ -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
- seamless: { borderColor: "transparent", backgroundColor: "transparent", paddingHorizontal: 0 },
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
- * 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.
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;
@@ -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
- });