@lotics/ui 19.1.0 → 20.0.1

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
 
@@ -115,6 +115,11 @@ One per type:
115
115
  GROWS with its content; only a plain label row is the fixed-height `MenuButton`. So a short
116
116
  code + the full official name underneath is a supported option shape, not a hack. Reach for it
117
117
  whenever the real name is too long to READ in a list but must stay exact somewhere.
118
+ **The popover is the FIELD's width** (floored at `MIN_CONTROL_WIDTH` so a dense grid cell still
119
+ opens something readable) — shared by `Select` and `Combobox`, every popover anchored to a
120
+ field. So option text longer than the field WRAPS; it never widens the panel, and a paragraph-
121
+ length option cannot open a list wider than the drawer or column the field lives in. Give a
122
+ long description line a `numberOfLines` clamp so rows stay even and the list stays scannable.
118
123
  - **`InlineMemberSelect`** — a `MemberChip` at rest → member picker; the inline twin of
119
124
  `MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact.
120
125
  - **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "19.1.0",
3
+ "version": "20.0.1",
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]}
@@ -51,7 +51,14 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
51
51
  onPress={() => onValueChange(option.value)}
52
52
  accessibilityRole="button"
53
53
  accessibilityLabel={`${accessibilityLabel}: ${option.label}`}
54
- accessibilityState={{ selected: active }}
54
+ // A TOGGLE BUTTON, so which chip is active is announced. It has to
55
+ // be a raw `aria-pressed`, not `accessibilityState`: react-native-web
56
+ // maps only the states with a native counterpart, and it drops
57
+ // `selected` on a `button` role — which left the active chip
58
+ // distinguished by nothing but a 1px ring, invisible to a screen
59
+ // reader (`RadioPicker` and `SegmentedControl` pass raw `aria-checked`
60
+ // for the same reason).
61
+ aria-pressed={active}
55
62
  // The shared pill-surface contract (height/border/radius/white +
56
63
  // hover/press wash + the selected box-shadow ring); the chip only
57
64
  // adds its horizontal padding.
@@ -5,6 +5,13 @@ import { colors } from "./colors";
5
5
  * buttons) aligns to it so a toolbar row reads as one band. */
6
6
  export const CONTROL_HEIGHT = 40;
7
7
 
8
+ /** The narrowest a control may be and still be usable — below it, editors get
9
+ * crushed and option text has nowhere to go. It is the threshold at which a
10
+ * `DetailTable` gives up its side-by-side columns and stacks, and the floor
11
+ * under a field popover's width, so "too narrow to use" means one thing across
12
+ * the kit rather than a number re-picked per component. */
13
+ export const MIN_CONTROL_WIDTH = 160;
14
+
8
15
  /** The system control radius — every interactive control surface (Button, the
9
16
  * inputs/selects/pickers, SearchInput, MenuButton, Tabs, the SegmentedControl
10
17
  * track, and ChipGroup/FilterChip/Chip via the surfaces below) wears it,
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,5 +1,6 @@
1
1
  import { createContext, ReactNode, useContext, useState } from "react";
2
2
  import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
3
+ import { MIN_CONTROL_WIDTH } from "./control_surface";
3
4
  import { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
4
5
  import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
5
6
  import { getInputLineHeight } from "./text_utils";
@@ -16,10 +17,6 @@ interface DetailTableColumns {
16
17
 
17
18
  const DetailTableContext = createContext<DetailTableColumns | null>(null);
18
19
 
19
- /** The value cell must keep at least this width before the table gives up its
20
- * side-by-side columns and stacks — below it, editors get crushed. */
21
- const MIN_VALUE_WIDTH = 160;
22
-
23
20
  /** THE kit's label column — ~18 characters, where real field names live. It is
24
21
  * this table's default AND a `TaskList`'s, so a record's fields and a task's
25
22
  * read as one language on a surface carrying both; that only holds while the
@@ -73,7 +70,7 @@ export interface DetailTableProps {
73
70
  * the switch is automatic.
74
71
  */
75
72
  export function DetailTable(props: DetailTableProps) {
76
- const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_VALUE_WIDTH, children, style } = props;
73
+ const { labelWidth = DETAIL_LABEL_WIDTH, trailingWidth, minHeight = INLINE_CONTROL_HEIGHT, minValueWidth = MIN_CONTROL_WIDTH, children, style } = props;
77
74
  const [width, setWidth] = useState<number | null>(null);
78
75
  const stacked =
79
76
  width != null && width < labelWidth + (trailingWidth ?? 0) + minValueWidth + 24;
package/src/popover.tsx CHANGED
@@ -9,6 +9,7 @@ import React, {
9
9
  } from "react";
10
10
  import { ScrollView, View, ViewStyle, StyleProp } from "react-native";
11
11
  import { colors } from "./colors";
12
+ import { MIN_CONTROL_WIDTH } from "./control_surface";
12
13
  import { IconButton } from "./icon_button";
13
14
  import { Portal } from "./portal";
14
15
  import { Divider } from "./divider";
@@ -641,10 +642,27 @@ export function PopoverContent(props: PopoverContentProps) {
641
642
  display: "flex",
642
643
  flexDirection: "column",
643
644
  }),
645
+ // INHERIT means inherit — the width, not just a floor under it. The
646
+ // flag is set only by a popover anchored to a FIELD (Select,
647
+ // InlineSelect, Combobox), and a field's own width is the right width
648
+ // for the list of values that field can hold: option text longer than
649
+ // the field belongs on a second line, not on a wider panel.
650
+ //
651
+ // As a floor alone this LOOKED right, because with short options
652
+ // max-content lands on the trigger width anyway. It broke the moment
653
+ // an option was a sentence: the panel grew to the `maxWidth: 800` cap
654
+ // and then slid sideways to stay on screen, so a 371px field opened an
655
+ // 800px list starting 421px to its left, outside the drawer that owns
656
+ // the field.
657
+ //
658
+ // Floored at MIN_CONTROL_WIDTH so a genuinely narrow trigger — a
659
+ // `variant="cell"` select in a dense grid column — still opens a list
660
+ // wide enough to read, rather than inheriting a width nothing fits in.
644
661
  ...(inheritTriggerWidth &&
645
662
  !small &&
646
663
  triggerWidth > 0 && {
647
- minWidth: triggerWidth,
664
+ width: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
665
+ maxWidth: Math.max(triggerWidth, MIN_CONTROL_WIDTH),
648
666
  }),
649
667
  }}
650
668
  onClick={(e) => e.stopPropagation()}
@@ -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
- }