@lotics/ui 19.1.0 → 20.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/MIGRATION.md CHANGED
@@ -4,6 +4,41 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## v20 from 19.x
8
+
9
+ **`@lotics/ui/section` is DELETED.** Two modules exported a `Section` — the layout grammar's
10
+ card-less region (`section_heading`) and a standalone titled block with its own `title` string,
11
+ `collapsible` flag and imperative `SectionHandlers.expand/collapse`. The second one predated both
12
+ the fixed heading ramp and the `Accordion` family, so a collapsible section had a heading that
13
+ could not take the ramp's rungs and a disclosure that could not be driven from state.
14
+
15
+ A collapsible section is now the `Accordion` family with a heading rung composed inside it — one
16
+ heading family and one disclosure across static and collapsible. Replace the imperative handle
17
+ with the controlled `expanded`/`onToggle` pair (or `defaultExpanded` when nothing outside needs to
18
+ drive it).
19
+
20
+ ```tsx
21
+ // BEFORE
22
+ const ref = useRef<SectionHandlers>(null);
23
+ <Section ref={ref} icon="eye-off" title="Hidden fields" collapsible initialCollapsed>
24
+ {body}
25
+ </Section>
26
+ // …elsewhere: ref.current?.expand()
27
+
28
+ // AFTER
29
+ <Accordion expanded={open} onToggle={setOpen}>
30
+ <AccordionHeader>
31
+ <DialogSectionHeadingTitle icon="eye-off">Hidden fields</DialogSectionHeadingTitle>
32
+ </AccordionHeader>
33
+ <AccordionContent>{body}</AccordionContent>
34
+ </Accordion>
35
+ ```
36
+
37
+ Pick the heading rung by where the surface sits, exactly as for a static section:
38
+ `SectionHeadingTitle` (`##`), `SubsectionHeadingTitle` (`###`), `DialogSectionHeadingTitle`
39
+ (`####`). The last one gained `icon` and `description` in this release, so every rung of the ramp
40
+ now carries the same slots.
41
+
7
42
  ## v19 from 18.x
8
43
 
9
44
  **`TaskFields` is DELETED, with `fieldsWidth`.** A task row was carrying a
package/docs/catalog.md CHANGED
@@ -150,7 +150,10 @@ Two columnar shapes, and the choice is about data size:
150
150
  `TrendChip` (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG
151
151
  set — no recharts), `RingGauge`, `ProgressBar` (its `compact` prop = ONE row, track + a
152
152
  plain sm tabular count beside it — the cell/heading/peek-trigger meter; a caption floating
153
- above a tiny bar reads misaligned) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
153
+ above a tiny bar reads misaligned. **The track clamps at 100%, the caption does not** — over
154
+ its max it reads `2,100 / 2,000 · 105%`, because a meter that says "100%" when you are over
155
+ tells the reader they are exactly at the limit. Numbers format in the reader's locale, so
156
+ never hand-format the value you pass in) / `StackedProgressBar` / `StepProgress`, `Breakdown` (a
154
157
  stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
155
158
  "Show N more" toggle — `labels` to localize — so several facet cards align to one height in
156
159
  a row), `Funnel` (a CONVERSION funnel — ordered stages as bars that NARROW; the step
@@ -172,6 +175,8 @@ to drill).
172
175
  gap-spaced region + `SectionHeading`/`SectionHeadingTitle`/`SectionHeadingMeta`; no body
173
176
  component — children are the body), `Subsection` (+ `SubsectionHeading`/
174
177
  `SubsectionHeadingTitle` — the named group INSIDE a section, `###` lg-semibold title),
178
+ `DialogSectionHeadingTitle` (`####` md-semibold — the rung for a surface inside a DIALOG, whose
179
+ `DialogHeaderTitle` already occupies lg; composes with the Subsection layout),
175
180
  `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
176
181
  top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
177
182
  space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
@@ -378,19 +383,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
378
383
  `warning`/`error` render as an ARIA `alert`) — wrapping form fields in an alert is wrong.
379
384
  The law: **content + fields → `Inset`; a message → `Callout`.**
380
385
  - **`section_heading`** — `Section` · `SectionHeading` · `SectionHeadingTitle` ·
381
- `SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle` — the
386
+ `SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle` ·
387
+ `DialogSectionHeadingTitle` — the
382
388
  card-less twin of the Card family, compound, owns no margin; spacing via the Section gap
383
389
  (16, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
384
390
  `weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
385
391
  same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
386
392
  title of a named group inside a section (same `info` ⓘ affordance as the section title) —
387
- heading-row siblings ride its right edge; the
388
- heading ramp is FIXED: `#` xxl / `##` xl / `###` lg, no size props.
389
- - **`section`** a SECOND, standalone `Section`: a self-contained titled block
390
- (`title`/`description`/`collapsible`/`icon`/`titleRight`, imperative
391
- `SectionHandlers.expand/collapse`). **Warning:** two modules export a `Section` — the
392
- layout grammar's card-less region is the one in `section_heading`; import from
393
- `@lotics/ui/section` only when you specifically want this collapsible titled block.
393
+ heading-row siblings ride its right edge. `DialogSectionHeadingTitle` is the `####`
394
+ md-semibold rung with the SAME `icon`/`description`/`info` slots as the section title, so a
395
+ dialog surface loses only the type size, never an affordance. The heading ramp is FIXED:
396
+ `#` xxl / `##` xl / `###` lg / `####` md, no size props.
394
397
  - **`section_stack`** — `SectionStack` · `SubsectionStack` — stacks that own the
395
398
  between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
396
399
  `Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
@@ -53,12 +53,14 @@ restyle a heading level per-page.
53
53
  slots carry each section's count and CTA); a `Text size="sm" weight="semibold"` stand-in is only
54
54
  for small sub-groups. Do NOT wrap each section in its own `CardHeader`, do NOT hand-roll
55
55
  sm-semibold lead lines on a flat page, and NEVER use a bare eyebrow as a section title.
56
-
57
- **Warning:** two different `Section` components ship. The compound one described here comes from
58
- `@lotics/ui/section_heading` (alongside `SectionHeading`/`SectionHeadingTitle`/
59
- `SectionHeadingMeta`/`Subsection`/…). `@lotics/ui/section` exports a LEGACY collapsible
60
- `Section` with a `title` string prop a different component. Import from `section_heading` for
61
- the grammar described here.
56
+ - **Dialog section title** — the FOURTH rung, for a surface hosted in a dialog:
57
+ `DialogSectionHeadingTitle` (`####` md semibold, with the same `icon`/`description`/`info`
58
+ slots as `SectionHeadingTitle`, so only the type size steps down). It exists because
59
+ `DialogHeaderTitle` is **lg** — the rung `SubsectionHeadingTitle` occupies — so a dialog holding
60
+ a multi-section surface otherwise ties with, or is outranked by, its own content. Compose it
61
+ with the SUBSECTION layout (`Subsection` › `SubsectionHeading` › this, in a `SubsectionStack`):
62
+ only the type size steps down, the 12px body gap and 32px beat are already right. The ladder
63
+ stays ABSOLUTE — this is one more fixed size, never a `size`/`compact` knob on a rung above.
62
64
  - **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
63
65
  `SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
64
66
  affordance as `SectionHeadingTitle`; siblings — a `Badge`, a
@@ -233,10 +235,14 @@ below the pressed element — the composable `Accordion` family (`AccordionHeade
233
235
  / `AccordionMeta` / `AccordionContent`); a header-only accordion renders a plain row of identical
234
236
  rhythm, so lists mix expandable + static rows. The body is **flush** with the header's left edge
235
237
  (aligns with the title — like a `Section` body, no indent, no tinted well). `AccordionHeader` is a
236
- layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but for a
237
- **collapsible Section** (configurable font/level/description + count) compose the SAME
238
- `SectionHeadingTitle`/`SectionHeadingMeta` inside it one heading family across static +
239
- collapsible.
238
+ layout slot: `AccordionTitle`/`AccordionMeta` is the compact list-row heading, but a **collapsible
239
+ SECTION** is this same family with the heading rung composed inside it — `SectionHeadingTitle` /
240
+ `SubsectionHeadingTitle` / `DialogSectionHeadingTitle` (+ `SectionHeadingMeta`) in the
241
+ `AccordionHeader`, the body in `AccordionContent`. There is no separate collapsible-section
242
+ component: one heading family and one disclosure across static + collapsible. The disclosure
243
+ header IS the section heading — name it for what it hides, never a generic bucket ("Advanced",
244
+ "More", "Options") wrapped around one named section: that stacks two titles of the same rank and
245
+ leaves the reader no reason to open the chevron.
240
246
 
241
247
  ## No dead rows — registers & tables
242
248
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "19.1.0",
3
+ "version": "20.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -195,7 +195,6 @@
195
195
  "./dots_indicator": "./src/dots_indicator.tsx",
196
196
  "./list": "./src/list.tsx",
197
197
  "./list_item": "./src/list_item.tsx",
198
- "./section": "./src/section.tsx",
199
198
  "./section_heading": "./src/section_heading.tsx",
200
199
  "./section_stack": "./src/section_stack.tsx",
201
200
  "./separator": "./src/separator.tsx",
package/src/accordion.tsx CHANGED
@@ -103,7 +103,10 @@ export function AccordionHeader(props: AccordionHeaderProps) {
103
103
  <PressableHighlight
104
104
  focusRing onPress={toggle}
105
105
  accessibilityRole="button"
106
- accessibilityState={{ expanded }}
106
+ // The W3C prop, NOT `accessibilityState` this react-native-web build drops the
107
+ // latter silently, so a disclosure shipped a `role="button"` that never announced
108
+ // whether its content was showing.
109
+ aria-expanded={expanded}
107
110
  accessibilityLabel={accessibilityLabel}
108
111
  style={[styles.headerRow, styles.pressable, style]}
109
112
  >
package/src/breakdown.tsx CHANGED
@@ -113,7 +113,8 @@ export function Breakdown(props: BreakdownProps) {
113
113
  {collapsible ? (
114
114
  <PressableHighlight
115
115
  focusRing accessibilityRole="button"
116
- accessibilityState={{ expanded }}
116
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web build.
117
+ aria-expanded={expanded}
117
118
  accessibilityLabel={expanded ? labels.less : labels.more(hidden)}
118
119
  onPress={() => setExpanded((e) => !e)}
119
120
  style={[styles.row, styles.pressable, styles.toggle]}
package/src/data_grid.tsx CHANGED
@@ -75,7 +75,8 @@ export function DataGrid<T>(props: DataGridProps<T>) {
75
75
  <FocusRingPressable
76
76
  onPress={() => onToggleCollapse?.(g.key)}
77
77
  accessibilityRole="button"
78
- accessibilityState={{ expanded: !isCollapsed }}
78
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web build.
79
+ aria-expanded={!isCollapsed}
79
80
  disabled={!onToggleCollapse}
80
81
  style={({ hovered }: { hovered?: boolean }) => [styles.section, hovered && onToggleCollapse ? styles.sectionHover : null]}
81
82
  >
@@ -1,6 +1,7 @@
1
1
  import { View, StyleSheet, type ViewStyle } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
+ import { useLocaleTag } from "./locale";
4
5
 
5
6
  export type ProgressBarFormat = "percentage" | "fraction" | "none";
6
7
 
@@ -12,7 +13,8 @@ export interface ProgressBarProps {
12
13
  * it. */
13
14
  title?: string;
14
15
  /** Caption above-right of the bar: `percentage` → "50%", `fraction` →
15
- * "1.250 / 2.500 · 50%". */
16
+ * "1,250 / 2,500 · 50%" (separators follow the reader's locale). Reports the
17
+ * TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
16
18
  format?: ProgressBarFormat;
17
19
  color?: string;
18
20
  completeColor?: string;
@@ -40,12 +42,23 @@ export function ProgressBar(props: ProgressBarProps) {
40
42
  compact = false,
41
43
  } = props;
42
44
 
43
- const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
44
- const isComplete = percentage >= 100;
45
+ // The pack is locale-aware (see `RemainderMeter`); a hardcoded tag renders 2.100 for an
46
+ // English reader, which reads as 2.1.
47
+ const localeTag = useLocaleTag();
48
+ /**
49
+ * The TRACK clamps — a fill wider than its own track is meaningless. The CAPTION does not: a
50
+ * meter reading `2.100 / 2.000 · 100%` asserts a number that is false, and a reader scanning
51
+ * percentages sees "exactly at the limit" when they are over it. Progress-toward-completion
52
+ * never exceeds its max, so this only diverges for the over-allowance case, where the true
53
+ * figure is the whole point.
54
+ */
55
+ const ratio = max > 0 ? Math.max(0, (value / max) * 100) : 0;
56
+ const percentage = Math.min(100, ratio);
57
+ const isComplete = ratio >= 100;
45
58
 
46
59
  if (compact) {
47
60
  const label =
48
- format === "percentage" ? `${Math.round(percentage)}%` : `${value.toLocaleString("vi-VN")}/${max.toLocaleString("vi-VN")}`;
61
+ format === "percentage" ? `${Math.round(ratio)}%` : `${value.toLocaleString(localeTag)}/${max.toLocaleString(localeTag)}`;
49
62
  return (
50
63
  <View style={styles.compactRow}>
51
64
  <View style={[styles.track, styles.compactTrack]}>
@@ -62,9 +75,9 @@ export function ProgressBar(props: ProgressBarProps) {
62
75
 
63
76
  const caption =
64
77
  format === "fraction"
65
- ? `${value.toLocaleString("vi-VN")} / ${max.toLocaleString("vi-VN")} · ${Math.round(percentage)}%`
78
+ ? `${value.toLocaleString(localeTag)} / ${max.toLocaleString(localeTag)} · ${Math.round(ratio)}%`
66
79
  : format === "percentage"
67
- ? `${Math.round(percentage)}%`
80
+ ? `${Math.round(ratio)}%`
68
81
  : null;
69
82
 
70
83
  return (
@@ -110,6 +110,64 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
110
110
  );
111
111
  }
112
112
 
113
+ export interface DialogSectionHeadingTitleProps {
114
+ children: React.ReactNode;
115
+ /** Optional muted line under the title, as on `SectionHeadingTitle`. */
116
+ description?: string;
117
+ /** Leading glyph, as on `SectionHeadingTitle`. */
118
+ icon?: IconName;
119
+ /** Heading rank. Defaults to 4 — one level under `SubsectionHeadingTitle`'s 3. */
120
+ level?: HeadingLevel;
121
+ /** An ⓘ popover after the title, as on the two rungs above. */
122
+ info?: string;
123
+ }
124
+
125
+ /**
126
+ * The section title for a surface hosted in a DIALOG — ALWAYS `####` (md semibold), the fourth
127
+ * rung of the same fixed ladder.
128
+ *
129
+ * It exists because `DialogHeaderTitle` is `lg`, which is the rung `SubsectionHeadingTitle`
130
+ * occupies: a dialog holding a multi-section surface has its own title tying with, or being
131
+ * outranked by, its content. The ladder stays ABSOLUTE — this is one more fixed size, never a
132
+ * knob on an existing rung. Do not reach for a `size`/`compact` prop on the titles above; the same
133
+ * rank rendering at two sizes is exactly what the fixed ramp prevents.
134
+ *
135
+ * Compose it with the SUBSECTION layout — `Subsection` › `SubsectionHeading` › this, inside a
136
+ * `SubsectionStack`. Only the type size steps down; the 12px body gap and the 32px between-group
137
+ * beat are already right for a dialog, so duplicating them under a `Dialog*` name would add three
138
+ * exports that differ from their siblings in nothing.
139
+ *
140
+ * <SubsectionStack divided>
141
+ * <Subsection>
142
+ * <SubsectionHeading>
143
+ * <DialogSectionHeadingTitle info="…">Usage</DialogSectionHeadingTitle>
144
+ * <SectionHeadingMeta>Aug 2 – Sep 2</SectionHeadingMeta>
145
+ * </SubsectionHeading>
146
+ * {body}
147
+ * </Subsection>
148
+ * </SubsectionStack>
149
+ */
150
+ export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps) {
151
+ const { children, description, icon, level = 4, info } = props;
152
+ const words = useLoticsLocale();
153
+ return (
154
+ <View style={{ flex: 1, gap: 2 }}>
155
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
156
+ {icon ? <Icon name={icon} size={16} /> : null}
157
+ <Text level={level} size="md" weight="semibold">
158
+ {children}
159
+ </Text>
160
+ {info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
161
+ </View>
162
+ {description ? (
163
+ <Text color="zinc-500" size="sm">
164
+ {description}
165
+ </Text>
166
+ ) : null}
167
+ </View>
168
+ );
169
+ }
170
+
113
171
  export interface SectionHeadingProps {
114
172
  children: React.ReactNode;
115
173
  style?: StyleProp<ViewStyle>;
package/src/select.tsx CHANGED
@@ -228,11 +228,13 @@ function SelectTrigger<T extends string>({
228
228
  testID={testID}
229
229
  // Without a role this Pressable renders as an unfocusable <div> on web — the
230
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; `expanded`
232
- // announces open/closed to assistive tech.
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.
233
234
  accessibilityRole="button"
234
235
  accessibilityLabel={accessibilityLabel}
235
- accessibilityState={{ expanded: open, disabled }}
236
+ aria-expanded={open}
237
+ aria-disabled={disabled}
236
238
  style={(state) => [styles.pressable, CONTROL_TRANSITION, open && styles.opened, disabled && styles.disabled, style, state.hovered && !disabled && { borderColor: HOVER_BORDER }]}
237
239
  onPress={!disabled ? onPress : undefined}
238
240
  disabled={disabled}
@@ -9,7 +9,9 @@ function SheetTab(props: { selected: boolean; onPress: () => void; name: string
9
9
  return (
10
10
  <FocusRingPressable
11
11
  accessibilityRole="tab"
12
- accessibilityState={{ selected: props.selected }}
12
+ // The W3C prop — `accessibilityState` is dropped by this react-native-web build, and a
13
+ // tab that never announces `aria-selected` leaves the tablist with no current item.
14
+ aria-selected={props.selected}
13
15
  onPress={props.onPress}
14
16
  style={[styles.tab, props.selected ? styles.tabActive : null]}
15
17
  >
package/src/section.tsx DELETED
@@ -1,101 +0,0 @@
1
- import { Pressable, View } from "react-native";
2
- import { Text } from "./text";
3
- import { useState, useImperativeHandle } from "react";
4
- import { Icon, IconName } from "./icon";
5
- import { Spacer } from "./spacer";
6
- import { FOCUS_RING } from "./control_surface";
7
- import { useFocusRing } from "./use_focus_ring";
8
-
9
- export interface SectionHandlers {
10
- expand: () => void;
11
- collapse: () => void;
12
- }
13
-
14
- interface SectionProps {
15
- children: React.ReactNode;
16
- title: string;
17
- description?: string;
18
- collapsible?: boolean;
19
- icon?: IconName;
20
- titleRight?: React.ReactNode;
21
- initialCollapsed?: boolean;
22
- ref?: React.Ref<SectionHandlers>;
23
- }
24
-
25
- export function Section(props: SectionProps) {
26
- const {
27
- children,
28
- title,
29
- icon,
30
- description,
31
- collapsible,
32
- titleRight,
33
- initialCollapsed = false,
34
- ref,
35
- } = props;
36
- const [collapsed, setCollapsed] = useState(collapsible && initialCollapsed);
37
-
38
- useImperativeHandle(ref, () => ({
39
- expand: () => {
40
- if (collapsible) {
41
- setCollapsed(false);
42
- }
43
- },
44
- collapse: () => {
45
- if (collapsible) {
46
- setCollapsed(true);
47
- }
48
- },
49
- }));
50
-
51
- const { focusVisible, focusProps } = useFocusRing();
52
-
53
- const titleComponent = collapsible ? (
54
- <Pressable
55
- {...focusProps}
56
- style={[{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }, focusVisible && { boxShadow: FOCUS_RING, borderRadius: 4 }]}
57
- onPress={() => setCollapsed(!collapsed)}
58
- >
59
- <Icon name={collapsed ? "chevron-right" : "chevron-down"} size={20} />
60
- {icon && <Icon name={icon} size={20} />}
61
- <Text size="md" weight="medium" userSelect="none">
62
- {title}
63
- </Text>
64
- </Pressable>
65
- ) : (
66
- <View style={{ flex: 1 }}>
67
- <Text size="md" weight="medium">
68
- {title}
69
- </Text>
70
- </View>
71
- );
72
-
73
- return (
74
- <View>
75
- <View
76
- style={{
77
- flexDirection: "row",
78
- alignItems: "center",
79
- height: 40,
80
- paddingBottom: 8,
81
- }}
82
- >
83
- {titleComponent}
84
- {titleRight}
85
- </View>
86
- {!collapsed && (
87
- <>
88
- {!!description && (
89
- <>
90
- <Text size="sm" color="zinc-500">
91
- {description}
92
- </Text>
93
- <Spacer size={16} />
94
- </>
95
- )}
96
- {children}
97
- </>
98
- )}
99
- </View>
100
- );
101
- }