@lotics/ui 23.1.1 → 23.2.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 CHANGED
@@ -54,6 +54,11 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
54
54
  (a CTA, ⋯, a checkbox, a `Link`) is a role-less `PressableRow` + a **`PressDoor`** sibling
55
55
  (the tab stop, name, focus ring), never `PressableHighlight`, which wraps its children in the
56
56
  button. `Table`/`TableRow` already does this. → [composition.md](./docs/composition.md).
57
+ - **A fact every row needs is a COLUMN; a fact one row needs expands in place.** Sending the
58
+ reader to a drawer for either loses the surrounding rows and charges a navigation to come
59
+ back — which is what makes scanning twenty records unaffordable. `TableRow`'s `detail` +
60
+ `expanded` reveal the detail beneath the row; keep the drawer for a heavy form.
61
+ → [catalog.md](./docs/catalog.md).
57
62
  - **A reference to another record is a FIELD** — it wears the inline editor's own resting
58
63
  surface, carries an **`InlineButton`** Open inside it, and PEEKS its facts on press (detach
59
64
  lives in the peek). Never its own Section, never a card, never the other record's fields as
package/docs/catalog.md CHANGED
@@ -129,8 +129,8 @@ See [`tpl_item_list`](../examples/tpl_item_list.tsx) for the checklist grammar,
129
129
  Two columnar shapes, and the choice is about data size:
130
130
 
131
131
  - **High-volume register** (thousands+ you BROWSE) — `Table` (columns defined once; sortable
132
- headers via `SortHeader`; paired with `Pagination`) + read-only rows that open a `Drawer`
133
- to edit. It scales by PAGING — renders one page, never the whole set — so you FILTER +
132
+ headers via `SortHeader`; paired with `Pagination`) + rows that either open a `Drawer` to
133
+ edit or expand in place (`detail` + `expanded`) when the detail is read or light-edit. It scales by PAGING — renders one page, never the whole set — so you FILTER +
134
134
  search, you don't group. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx).
135
135
  Never an HTML `<table>` or a `.map` of rows. The register adapts to its container on its
136
136
  own — hides columns by `priority`, stacks rows below the two-column floor (see the `table`
@@ -807,6 +807,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
807
807
  On pressable rows dropped values stay one tap away — the row opens the record; a READ-ONLY
808
808
  register (rows without `onPress`) has no door, so give the columns it can't afford to lose
809
809
  a low `priority` (e.g. `priority: 1` — outlives its right-side neighbours).
810
+ **`detail` + `expanded` reveal a row's detail BENEATH it instead of navigating away.** A
811
+ register answers "which one"; the moment it cannot answer "and what about it" the reader is
812
+ sent to a drawer, loses the surrounding rows, and pays a navigation to come back — which is
813
+ what makes scanning twenty records unaffordable. Reach for it when the detail is read or
814
+ light-edit; a heavy form still deserves its own surface. `expanded` is CONTROLLED — how many
815
+ may be open at once is the caller's rule, so the component does not pick one. The detail
816
+ renders as a SIBLING of the press surface (a control inside it would otherwise be swallowed
817
+ by the row's own toggle), the row shows the open wash while it is out, and the door announces
818
+ `aria-expanded`. Prefer the columns first: a fact every row needs is a column, not a reason
819
+ to expand.
810
820
  - **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
811
821
  `SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
812
822
  `DataGrid` consumers drive.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "23.1.1",
3
+ "version": "23.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/breakdown.tsx CHANGED
@@ -97,7 +97,9 @@ export function Breakdown(props: BreakdownProps) {
97
97
  <PressableHighlight
98
98
  focusRing key={item.key}
99
99
  accessibilityRole="button"
100
- accessibilityState={{ selected }}
100
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
101
+ // build, and `selected` has no meaning on a `button` role regardless.
102
+ aria-pressed={selected}
101
103
  accessibilityLabel={`${item.label}: ${formatValue(item.value)}`}
102
104
  onPress={() => onSelect(selected ? null : item.key)}
103
105
  style={[styles.row, styles.pressable, selected ? styles.selected : null]}
@@ -46,7 +46,9 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
46
46
  <Pressable
47
47
  accessibilityRole="button"
48
48
  accessibilityLabel={option.label}
49
- accessibilityState={{ selected }}
49
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
50
+ // build, and `selected` has no meaning on a `button` role regardless.
51
+ aria-pressed={selected}
50
52
  onPress={onSelect}
51
53
  {...focusProps}
52
54
  style={({ hovered, pressed }) => [
@@ -31,7 +31,9 @@ function DayCell(props: {
31
31
  <Pressable
32
32
  accessibilityRole="button"
33
33
  accessibilityLabel={props.label}
34
- accessibilityState={{ selected: props.isActive }}
34
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
35
+ // build, and `selected` has no meaning on a `button` role regardless.
36
+ aria-pressed={props.isActive}
35
37
  {...focusProps}
36
38
  style={({ hovered }) => [
37
39
  styles.dayCell,
package/src/funnel.tsx CHANGED
@@ -91,7 +91,9 @@ export function Funnel(props: FunnelProps) {
91
91
  return (
92
92
  <PressableHighlight
93
93
  accessibilityRole="button"
94
- accessibilityState={{ selected: selectedKey === key }}
94
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
95
+ // build, and `selected` has no meaning on a `button` role regardless.
96
+ aria-pressed={selectedKey === key}
95
97
  accessibilityLabel={label}
96
98
  onPress={() => onSelect(selectedKey === key ? null : key)}
97
99
  style={fill ? styles.pressFill : undefined}
package/src/heatmap.tsx CHANGED
@@ -108,7 +108,9 @@ function Cell(props: CellProps) {
108
108
  return (
109
109
  <Pressable
110
110
  accessibilityRole="button"
111
- accessibilityState={{ selected }}
111
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
112
+ // build, and `selected` has no meaning on a `button` role regardless.
113
+ aria-pressed={selected}
112
114
  accessibilityLabel={label}
113
115
  onPress={onPress}
114
116
  {...focusProps}
package/src/matrix.tsx CHANGED
@@ -222,7 +222,9 @@ function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
222
222
  return (
223
223
  <FocusRingPressable
224
224
  accessibilityRole="button"
225
- accessibilityState={{ selected: isSelected }}
225
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
226
+ // build, and `selected` has no meaning on a `button` role regardless.
227
+ aria-pressed={isSelected}
226
228
  accessibilityLabel={label}
227
229
  onPress={() => onSelectCell?.(isSelected ? null : { row: row.key, col: col.key })}
228
230
  style={(state) => [
@@ -10,6 +10,13 @@ export interface PressDoorProps {
10
10
  * without a label it is a nameless button. Name the destination ("Open ORD-1042").
11
11
  */
12
12
  accessibilityLabel: string;
13
+ /**
14
+ * Set ONLY when the press toggles a disclosure the door owns (`TableRow`'s
15
+ * `detail`), never when it navigates: it emits `aria-expanded`, which promises
16
+ * a screen-reader user that this control shows and hides something in place.
17
+ * Omit and no state is announced.
18
+ */
19
+ expanded?: boolean;
13
20
  /** Focus-ring corner radius — match the surface the door spans. Default 10 (the register wash). */
14
21
  radius?: number;
15
22
  }
@@ -44,12 +51,16 @@ export interface PressDoorProps {
44
51
  *
45
52
  * `TableRow` is the in-kit consumer.
46
53
  */
47
- export function PressDoor({ onPress, accessibilityLabel, radius = 10 }: PressDoorProps) {
54
+ export function PressDoor({ onPress, accessibilityLabel, expanded, radius = 10 }: PressDoorProps) {
48
55
  const { focusVisible, focusProps } = useFocusRing();
49
56
  return (
50
57
  <Pressable
51
58
  accessibilityRole="button"
52
59
  accessibilityLabel={accessibilityLabel}
60
+ // The W3C prop, NOT `accessibilityState` — this react-native-web build drops
61
+ // the latter silently. Set only when the door TOGGLES something: on a door
62
+ // that navigates it would promise a disclosure the press does not perform.
63
+ aria-expanded={expanded}
53
64
  onPress={onPress}
54
65
  {...focusProps}
55
66
  style={[styles.door, { borderRadius: radius }, focusVisible && { boxShadow: FOCUS_RING }]}
@@ -133,7 +133,9 @@ export function StatusLegend(props: StatusLegendProps) {
133
133
  <PressableHighlight
134
134
  focusRing key={state.key}
135
135
  accessibilityRole="button"
136
- accessibilityState={{ selected }}
136
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web
137
+ // build, and `selected` has no meaning on a `button` role regardless.
138
+ aria-pressed={selected}
137
139
  accessibilityLabel={`${state.label}: ${count}`}
138
140
  onPress={() => onSelect(selected ? null : state.key)}
139
141
  style={[styles.legendItem, styles.legendPressable, selected ? styles.legendSelected : null]}
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,14 @@ 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
+ detail: {
343
+ paddingLeft: ROW_GUTTER,
344
+ paddingRight: ROW_GUTTER,
345
+ paddingBottom: 16,
346
+ },
304
347
  // A hairline under the column header anchors the columns; the rows below it are
305
348
  // Divider-separated.
306
349
  headerBand: {