@lotics/ui 19.0.1 → 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 +38 -2
- package/docs/catalog.md +52 -29
- package/docs/composition.md +16 -10
- package/docs/data_entry.md +33 -17
- package/package.json +1 -2
- package/src/accordion.tsx +4 -1
- package/src/breakdown.tsx +2 -1
- package/src/control_surface.ts +10 -0
- package/src/data_grid.tsx +2 -1
- package/src/detail_row.tsx +17 -49
- package/src/field_annotations.tsx +76 -0
- package/src/progress_bar.tsx +19 -6
- package/src/section_heading.tsx +58 -0
- package/src/select.tsx +5 -3
- package/src/spreadsheet_view.tsx +3 -1
- package/src/task.tsx +81 -63
- package/src/section.tsx +0 -101
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
|
|
@@ -44,8 +79,9 @@ Porting a row, cell by cell:
|
|
|
44
79
|
- **Every cell becomes one `TaskSubRow`, and it needs a NAME.** The column header the cells
|
|
45
80
|
never had is now the label — write what the value IS ("Due", "Assignee", "Completed on"), not
|
|
46
81
|
what it does.
|
|
47
|
-
- **Drop the wrapper `View`s and the widths.**
|
|
48
|
-
|
|
82
|
+
- **Drop the wrapper `View`s and the per-cell widths.** The label column is the LIST's
|
|
83
|
+
(`TaskList labelWidth`, default `DETAIL_LABEL_WIDTH`) and the value takes the slack, so a row
|
|
84
|
+
passes no width at all — set `labelWidth` once on the list if the vocabulary needs more.
|
|
49
85
|
- **Drop `variant="cell"`.** A cell variant is for a grid; a sub-row's value is a form-variant
|
|
50
86
|
`Inline*` editor (the default) — the zinc-50 chip that says "editable".
|
|
51
87
|
- **A row that needed SCANNABLE columns — the same four values compared down twenty rows — was
|
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
|
|
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` /
|
|
@@ -334,9 +339,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
334
339
|
hand-rolled plain DOM/CSS (nothing injects them automatically; `@lotics/ui` components
|
|
335
340
|
don't need them).
|
|
336
341
|
- **`spacing`** — the `SPACE` scale + `SpaceToken`.
|
|
337
|
-
- **`control_surface`** — `CONTROL_HEIGHT` (40) · `CONTROL_RADIUS` (10) · `
|
|
338
|
-
|
|
339
|
-
|
|
342
|
+
- **`control_surface`** — `CONTROL_HEIGHT` (40) · `CONTROL_RADIUS` (10) · `CONTROL_TEXT_INSET`
|
|
343
|
+
(9 — how far a control insets its OWN text: 1px border + 8px padding; anything that must line
|
|
344
|
+
up with a control's WORDS rather than its box carries it, and `TASK_TEXT_INSET` IS it) ·
|
|
345
|
+
`FOCUS_RING` · `HOVER_BORDER` · `CONTROL_TRANSITION` · `chipSurfaceStyle` — the shared
|
|
346
|
+
control-surface tokens.
|
|
340
347
|
- **`fonts.css`** — the Inter sheet (400/500/600, served by absolute URL so it resolves on
|
|
341
348
|
every origin an app runs from); the app entry imports it ONCE or every `Text` falls back
|
|
342
349
|
to system fonts.
|
|
@@ -376,19 +383,17 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
376
383
|
`warning`/`error` render as an ARIA `alert`) — wrapping form fields in an alert is wrong.
|
|
377
384
|
The law: **content + fields → `Inset`; a message → `Callout`.**
|
|
378
385
|
- **`section_heading`** — `Section` · `SectionHeading` · `SectionHeadingTitle` ·
|
|
379
|
-
`SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle`
|
|
386
|
+
`SectionHeadingMeta` · `Subsection` · `SubsectionHeading` · `SubsectionHeadingTitle` ·
|
|
387
|
+
`DialogSectionHeadingTitle` — the
|
|
380
388
|
card-less twin of the Card family, compound, owns no margin; spacing via the Section gap
|
|
381
389
|
(16, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
|
|
382
390
|
`weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
|
|
383
391
|
same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
|
|
384
392
|
title of a named group inside a section (same `info` ⓘ affordance as the section title) —
|
|
385
|
-
heading-row siblings ride its right edge
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
`SectionHandlers.expand/collapse`). **Warning:** two modules export a `Section` — the
|
|
390
|
-
layout grammar's card-less region is the one in `section_heading`; import from
|
|
391
|
-
`@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.
|
|
392
397
|
- **`section_stack`** — `SectionStack` · `SubsectionStack` — stacks that own the
|
|
393
398
|
between-block law, skipping null children: `SectionStack` = a fixed 56px beat + a hairline
|
|
394
399
|
`Divider` between top-level blocks; `SubsectionStack` = a fixed 32px beat, space-only while
|
|
@@ -708,9 +713,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
708
713
|
wire both); invalid/cross-field STATE = a co-located `Callout`; **`flat`**
|
|
709
714
|
on a flat-text value row (`InlineStatic`, plain `Text`) tucks the annotation up by the
|
|
710
715
|
control band's slack so its gap matches a chip row's.
|
|
716
|
+
The three annotations are ONE anatomy shared with a task's `TaskSubRow` — same names, same
|
|
717
|
+
order, same alert semantics — because a record's field and a task's field are the same thing
|
|
718
|
+
on two surfaces.
|
|
711
719
|
`DetailTable`: the compound parent of
|
|
712
|
-
a row STACK — `labelWidth` (default `DETAIL_LABEL_WIDTH`, 130 —
|
|
713
|
-
|
|
720
|
+
a row STACK — `labelWidth` (default `DETAIL_LABEL_WIDTH`, 130 — THE kit's label column, and
|
|
721
|
+
a `TaskList`'s default too) / `trailingWidth` / `minHeight` (default 40, the
|
|
714
722
|
inline-control grid) declared ONCE + the 8px row gap; with `trailingWidth` every row
|
|
715
723
|
reserves the trailing column so value cells share one width and trailing items align at
|
|
716
724
|
one x, like a table. RESPONSIVE with no prop: it measures its own container (onLayout, not
|
|
@@ -793,9 +801,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
793
801
|
from a 5-item drawer checklist to a grouped desk board. `TaskList` owns geometry only: the two
|
|
794
802
|
GUTTERS — `controlWidth` (the left one, that every title aligns on, and so the INDENT step) and
|
|
795
803
|
`actionWidth` (the right one, that every ⋯ pins into; `0` on a list whose rows carry no
|
|
796
|
-
actions) — plus `
|
|
797
|
-
|
|
798
|
-
|
|
804
|
+
actions) — plus `labelWidth` (the column every `TaskSubRow` renders its NAME in; default
|
|
805
|
+
`DETAIL_LABEL_WIDTH`, 130) and `density` (`comfortable` = a 44px minimum tap target, `dense` =
|
|
806
|
+
32 for a pointer-driven register). All four are inherited by nested lists. Everything else is
|
|
807
|
+
composed, and JSX order is screen order — nothing inspects child types.
|
|
799
808
|
|
|
800
809
|
`TaskStatus` takes the `CheckCircle` (omit `onChange` for a read-only ring; a PICKER list
|
|
801
810
|
puts a `CheckboxInput` here and sets `controlWidth={24}`). `TaskTitle` takes the
|
|
@@ -805,7 +814,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
805
814
|
`TASK_TEXT_INSET` is never needed and a long title that wraps keeps its first line beside the
|
|
806
815
|
control. It spans the content box between the gutters, like every line hung beneath it.
|
|
807
816
|
`TaskCaption` is the row's state IN WORDS on its own line under the title — a
|
|
808
|
-
sentence, never a field
|
|
817
|
+
sentence about the ROW, never a field, and never a sentence about one FIELD (that is the
|
|
818
|
+
sub-row's own `description` / `warning` / `error`).
|
|
819
|
+
`TaskActions` carries the row's ⋯ `ActionMenu`, pinned into the RIGHT
|
|
809
820
|
gutter on the first line (Delete lives BEHIND it, danger-styled and last, never a bare ✕).
|
|
810
821
|
`TaskDetail` is a FREE-FORM block under the row on the title's text edge — a chart, a table, a
|
|
811
822
|
form with its own submit — rendered only while open. The module also exports
|
|
@@ -822,23 +833,35 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
822
833
|
step inward per level. Declare `actionWidth={0}` when NO row in the list carries actions.
|
|
823
834
|
|
|
824
835
|
**THE ROW CARRIES THE TITLE; THE FIELDS THE USER CAN SET ARE SUB-ROWS BENEATH IT.** One task's
|
|
825
|
-
own fields hang under it as **`TaskSubRow`** (`label` · the control ·
|
|
826
|
-
`
|
|
827
|
-
is expressed by indentation and there is only one device for it. Label and value sit
|
|
828
|
-
so the eye pairs them
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
836
|
+
own fields hang under it as **`TaskSubRow`** (`label` · the control · `description` /
|
|
837
|
+
`warning` / `error`), indented ONE step — the same step a nested `TaskList` takes, because
|
|
838
|
+
belonging is expressed by indentation and there is only one device for it. Label and value sit
|
|
839
|
+
ADJACENT so the eye pairs them. **The label column is the LIST's, not the row's** — one
|
|
840
|
+
`labelWidth` for every sub-row at every depth, exactly as `DetailTable labelWidth` is the
|
|
841
|
+
record's — so every value on the surface starts on ONE x. A name longer than the column WRAPS
|
|
842
|
+
inside it and its value does not move; a column each row sized to its own words is not a
|
|
843
|
+
column (it started a short label's value at one x and a long one's at another, and any
|
|
844
|
+
vocabulary with longer field names staggered nearly every row). The value takes the slack from
|
|
845
|
+
a readable minimum — on a phone or in a narrow drawer it drops onto its own line under the
|
|
846
|
+
label rather than ellipsizing beside it. Pass a form-variant `Inline*` editor (the default),
|
|
847
|
+
not `variant="cell"`: a cell variant belongs to a grid.
|
|
848
|
+
|
|
849
|
+
**A TASK'S FIELD ANNOTATES EXACTLY LIKE A RECORD'S FIELD.** `description` (persistent
|
|
850
|
+
guidance), `warning` (a consequence to weigh — amber, announced) and `error` (field-level
|
|
851
|
+
failure — danger, alert semantics) are the same three props with the same order and a11y as
|
|
852
|
+
`DetailRow`'s, from ONE shared implementation. A fault in a single field belongs ON that
|
|
853
|
+
field; `TaskCaption` is a sentence about the whole ROW, so demoting a field fault into one
|
|
854
|
+
says something different.
|
|
834
855
|
|
|
835
856
|
**ANYTHING THAT NEEDS SCANNABLE COLUMNS IS A `Table`, NOT A `TaskList`.** The compound spent a
|
|
836
857
|
version carrying a declared value column so cells would line up down the list; it aligned and
|
|
837
858
|
it read worse — each field's name ended up a quarter of the surface from its control, sized for
|
|
838
859
|
the widest value on the page. If the job is comparing the same four values across twenty rows,
|
|
839
860
|
that is a table, and `Table`/`DataGrid` are built for it. A `DetailTable labelWidth={…}` inside
|
|
840
|
-
a `TaskDetail` is the other shape to avoid:
|
|
841
|
-
two x positions matching nothing above them — a form
|
|
861
|
+
a `TaskDetail` is the other shape to avoid: the list ALREADY has a label column, so a second
|
|
862
|
+
one lands its label and its value at two more x positions matching nothing above them — a form
|
|
863
|
+
pasted into a list. Fields belonging to the task are `TaskSubRow`s, which annotate the same way
|
|
864
|
+
a `DetailRow` does, so there is nothing left that a nested `DetailTable` can say.
|
|
842
865
|
|
|
843
866
|
**A row's FIRST LINE is a BAND** — 44px `comfortable`, 32 `dense`. Both gutter controls centre
|
|
844
867
|
in it and `TaskTitle` claims it, which is what keeps both columns of
|
package/docs/composition.md
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
`
|
|
60
|
-
|
|
61
|
-
the
|
|
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
|
|
237
|
-
**
|
|
238
|
-
`
|
|
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/docs/data_entry.md
CHANGED
|
@@ -272,30 +272,46 @@ second line — hangs below it. That is what keeps a column of controls straight
|
|
|
272
272
|
mixed-height rows, so a control never drifts into the gap under the title it belongs to.
|
|
273
273
|
|
|
274
274
|
A task's own free text is its TITLE, a **`TaskCaption`** or a **`TaskDetail`** — there is no
|
|
275
|
-
fourth place to write, because a fourth place gets written in twice. `TaskCaption`
|
|
276
|
-
state IN WORDS ("3 of 5 papers received", "waiting on the yard") on its own line
|
|
277
|
-
it is not a field, because a field is a NAMED VALUE the reader sets and a caption
|
|
278
|
-
about one row — labelling prose makes it read as a field nobody can edit.
|
|
275
|
+
fourth place to write about the TASK, because a fourth place gets written in twice. `TaskCaption`
|
|
276
|
+
is the row's state IN WORDS ("3 of 5 papers received", "waiting on the yard") on its own line
|
|
277
|
+
under the title; it is not a field, because a field is a NAMED VALUE the reader sets and a caption
|
|
278
|
+
is a SENTENCE about one row — labelling prose makes it read as a field nobody can edit. A sentence
|
|
279
|
+
about one FIELD is not a caption either: it is that sub-row's own `description` / `warning` /
|
|
280
|
+
`error`, which is why the sub-row carries all three.
|
|
279
281
|
|
|
280
282
|
**THE ROW CARRIES THE TITLE; THE FIELDS THE USER CAN SET ARE SUB-ROWS BENEATH IT.** A field
|
|
281
283
|
belonging to ONE task — its due date, its assignee, a portal login, a reference number — hangs
|
|
282
|
-
under it as a **`TaskSubRow`** (`label` · the control ·
|
|
283
|
-
ONE step, with the NAME beside its VALUE so the eye pairs them. Belonging is expressed by
|
|
284
|
-
indentation, which is the same step a nested `TaskList` takes — one device, at every depth.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
under it as a **`TaskSubRow`** (`label` · the control · `description` / `warning` / `error`),
|
|
285
|
+
indented ONE step, with the NAME beside its VALUE so the eye pairs them. Belonging is expressed by
|
|
286
|
+
that indentation, which is the same step a nested `TaskList` takes — one device, at every depth.
|
|
287
|
+
|
|
288
|
+
**The label column belongs to the LIST, not to the row** — `TaskList labelWidth`, defaulting to
|
|
289
|
+
`DETAIL_LABEL_WIDTH` (130), inherited to every depth, exactly as `DetailTable labelWidth` is the
|
|
290
|
+
record's. Every sub-row on the surface therefore starts its value on ONE x, and a name longer than
|
|
291
|
+
the column WRAPS inside it instead of pushing its own value right. A column each row sized to its
|
|
292
|
+
own words is not a column: it lined up the labels of a task whose names happened to be short and
|
|
293
|
+
staggered every other one, and a vocabulary with longer field names than English staggered nearly
|
|
294
|
+
all of them. Raise it once, on the list, for a vocabulary that genuinely needs more. The value
|
|
295
|
+
takes the slack from a readable minimum, dropping onto its own line under the label on a surface
|
|
296
|
+
too narrow to seat both. Use the form-variant `Inline*` editor (the default) — `variant="cell"`
|
|
297
|
+
belongs to a grid.
|
|
298
|
+
|
|
299
|
+
**A TASK'S FIELD ANNOTATES EXACTLY LIKE A RECORD'S FIELD.** `TaskSubRow` carries the same
|
|
300
|
+
`description` / `warning` / `error` as `DetailRow`, from one shared implementation — same tones,
|
|
301
|
+
same severity order (error nearest the control, then warning, then the guidance), same alert
|
|
302
|
+
semantics. A fault in ONE field says so on that field. Demoting it into a `TaskCaption` — a
|
|
303
|
+
sentence about the whole ROW — says something different, and that asymmetry is what forced apps
|
|
304
|
+
to do it.
|
|
290
305
|
|
|
291
306
|
**Anything that needs SCANNABLE COLUMNS is a `Table`, not a `TaskList`.** The compound once
|
|
292
|
-
carried a declared
|
|
307
|
+
carried a declared VALUE column so cells lined up down the list; it aligned, and it read worse —
|
|
293
308
|
each name ended up a quarter of the surface from its control, sized for the widest value on the
|
|
294
309
|
page. Comparing the same four values across twenty rows is a table's job. A `DetailTable` inside a
|
|
295
|
-
`TaskDetail` is the same mistake in miniature:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
310
|
+
`TaskDetail` is the same mistake in miniature: the list already HAS a label column, so a second
|
|
311
|
+
one lands a label and a control at two more x positions matching nothing above them — a form
|
|
312
|
+
pasted into a list, and with sub-row annotations there is nothing left for it to say.
|
|
313
|
+
`TaskDetail` keeps the FREE-FORM block (a chart, a table, a form with its own submit) — it has no
|
|
314
|
+
name to hang on the indent, so a rule down its left edge is what ties it to the row.
|
|
299
315
|
|
|
300
316
|
**The ⋯ is a COLUMN — the mirror of the control gutter.** Both pinned controls sit outside the
|
|
301
317
|
row's flow and the ROW reserves each edge (`controlWidth` on the left, `actionWidth` on the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
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
|
|
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
|
|
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/control_surface.ts
CHANGED
|
@@ -25,6 +25,16 @@ export const CONTROL_RADIUS = 10;
|
|
|
25
25
|
* one look. Apps building their own controls reuse it. */
|
|
26
26
|
export const FOCUS_RING = `0 0 0 2px ${colors.zinc[900]}`;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* How far a control surface insets its OWN text: 1px of border (transparent on an
|
|
30
|
+
* inline editor at rest) plus 8px of padding. Anything that has to line up with a
|
|
31
|
+
* control's WORDS rather than with its invisible box — a field's annotation under
|
|
32
|
+
* its value, a caption hung beneath a title — carries this same number. It is 9,
|
|
33
|
+
* not 8: reading the padding alone and forgetting the border is what put a whole
|
|
34
|
+
* family of hanging lines 1px left of the text they belonged to.
|
|
35
|
+
*/
|
|
36
|
+
export const CONTROL_TEXT_INSET = 9;
|
|
37
|
+
|
|
28
38
|
/** A bordered control's pointer-hover edge: the resting `border` darkens to this on
|
|
29
39
|
* hover — the premium "this is interactive" affordance that complements the focus
|
|
30
40
|
* ring (focus = the ring; hover = a darker border). Drive it with `useHover` on a
|
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
|
|
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
|
>
|
package/src/detail_row.tsx
CHANGED
|
@@ -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 { FieldAnnotationProps, FieldAnnotations, hasFieldAnnotation } from "./field_annotations";
|
|
3
4
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
4
5
|
import { getInputLineHeight } from "./text_utils";
|
|
5
6
|
import { Text } from "./text";
|
|
@@ -20,9 +21,11 @@ const DetailTableContext = createContext<DetailTableColumns | null>(null);
|
|
|
20
21
|
const MIN_VALUE_WIDTH = 160;
|
|
21
22
|
|
|
22
23
|
/** THE kit's label column — ~18 characters, where real field names live. It is
|
|
23
|
-
* this table's default AND
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* this table's default AND a `TaskList`'s, so a record's fields and a task's
|
|
25
|
+
* read as one language on a surface carrying both; that only holds while the
|
|
26
|
+
* two are ONE number. A name longer than the column WRAPS inside it: the
|
|
27
|
+
* column belongs to the SURFACE, so no one field's name may push its own value
|
|
28
|
+
* out of line with the field above it. */
|
|
26
29
|
export const DETAIL_LABEL_WIDTH = 130;
|
|
27
30
|
|
|
28
31
|
export interface DetailTableProps {
|
|
@@ -89,7 +92,7 @@ export function DetailTable(props: DetailTableProps) {
|
|
|
89
92
|
);
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
export interface DetailRowProps {
|
|
95
|
+
export interface DetailRowProps extends FieldAnnotationProps {
|
|
93
96
|
/** The field label — rendered `sm muted`. */
|
|
94
97
|
label: ReactNode;
|
|
95
98
|
/** The value — any node (a `Text`, a `Badge`, a stack). */
|
|
@@ -110,23 +113,6 @@ export interface DetailRowProps {
|
|
|
110
113
|
labelSize?: "xs" | "sm";
|
|
111
114
|
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
112
115
|
minHeight?: number;
|
|
113
|
-
/** PERSISTENT guidance — what to enter, or the rule that applies ("Printed
|
|
114
|
-
* on every document"). Renders under the VALUE (the label column has no
|
|
115
|
-
* room for prose); stacked mode mirrors `FormField` (label · description ·
|
|
116
|
-
* control). Always visible — field annotations are EXPLICIT, never a
|
|
117
|
-
* hidden ⓘ gloss. Invalid STATE → `error` or a co-located `Callout`. Same
|
|
118
|
-
* name, same meaning as `FormField.description`. */
|
|
119
|
-
description?: string;
|
|
120
|
-
/** A consequence the user should WEIGH before acting — renders in the same
|
|
121
|
-
* annotation slot in amber (`Text color="warning"`), one tone between the
|
|
122
|
-
* muted `description` and the danger `error`, announced on appearance. Same
|
|
123
|
-
* name, same meaning as `FormField.warning`. */
|
|
124
|
-
warning?: string;
|
|
125
|
-
/** Field-level failure, under the value in danger ink with `FormField`'s
|
|
126
|
-
* alert semantics (announced on appearance). For CONSUMER-validated state —
|
|
127
|
-
* the `Inline*` editors already render their own transient save errors;
|
|
128
|
-
* don't wire both to the same failure. */
|
|
129
|
-
error?: string;
|
|
130
116
|
/** The value renders as FLAT text (an `InlineStatic`, a plain `Text`) rather
|
|
131
117
|
* than a control-height chip. Annotations then tuck up by the control
|
|
132
118
|
* band's slack, so the perceived gap under the TEXT equals the gap under a
|
|
@@ -153,7 +139,7 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
153
139
|
const form = labelWidth != null;
|
|
154
140
|
// Annotations (description / warning / error) live UNDER THE VALUE — the row
|
|
155
141
|
// grows, so the label + trailing center against the CONTROL LINE, not the block.
|
|
156
|
-
const annotated =
|
|
142
|
+
const annotated = hasFieldAnnotation(props);
|
|
157
143
|
// A FLAT value (no painted chip) centers its text in the control band,
|
|
158
144
|
// leaving invisible slack below it — tuck the annotations up by that slack
|
|
159
145
|
// so the perceived gap matches a chip row's. Horizontal mode only (stacked
|
|
@@ -170,29 +156,11 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
170
156
|
</Text>
|
|
171
157
|
</View>
|
|
172
158
|
);
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
const annotations =
|
|
178
|
-
<>
|
|
179
|
-
{error != null ? (
|
|
180
|
-
<Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite" style={styles.annotation}>
|
|
181
|
-
{error}
|
|
182
|
-
</Text>
|
|
183
|
-
) : null}
|
|
184
|
-
{warning != null ? (
|
|
185
|
-
<Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite" style={styles.annotation}>
|
|
186
|
-
{warning}
|
|
187
|
-
</Text>
|
|
188
|
-
) : null}
|
|
189
|
-
{description != null ? (
|
|
190
|
-
<Text size="xs" color="muted" style={styles.annotation}>
|
|
191
|
-
{description}
|
|
192
|
-
</Text>
|
|
193
|
-
) : null}
|
|
194
|
-
</>
|
|
195
|
-
) : null;
|
|
159
|
+
// THE field-annotation anatomy — severity, tones, a11y and the control's own
|
|
160
|
+
// text inset, defined once in `field_annotations` and rendered identically by
|
|
161
|
+
// a task's `TaskSubRow`. A record's field and a task's field are the same
|
|
162
|
+
// thing on two surfaces; they may not say a fault two different ways.
|
|
163
|
+
const annotations = <FieldAnnotations description={description} warning={warning} error={error} />;
|
|
196
164
|
if (table?.stacked) {
|
|
197
165
|
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
198
166
|
// `FormField` label (medium, default ink), so a narrow record surface
|
|
@@ -234,12 +202,12 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
234
202
|
{form ? (
|
|
235
203
|
<View style={[styles.value, annotated && styles.valueStack]}>
|
|
236
204
|
<View style={[styles.controlLine, { minHeight }]}>{children}</View>
|
|
237
|
-
{flatTuck !== 0
|
|
205
|
+
{flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
|
|
238
206
|
</View>
|
|
239
207
|
) : (
|
|
240
208
|
<View style={annotated ? styles.valueStack : undefined}>
|
|
241
209
|
<View style={[styles.controlLine, { minHeight }]}>{children}</View>
|
|
242
|
-
{flatTuck !== 0
|
|
210
|
+
{flatTuck !== 0 ? <View style={{ marginTop: flatTuck }}>{annotations}</View> : annotations}
|
|
243
211
|
</View>
|
|
244
212
|
)}
|
|
245
213
|
{table?.trailingWidth != null ? (
|
|
@@ -279,9 +247,9 @@ const styles = StyleSheet.create({
|
|
|
279
247
|
// The first line of the value cell — the control centers in it exactly like
|
|
280
248
|
// the label and trailing cells do.
|
|
281
249
|
controlLine: { justifyContent: "center" },
|
|
250
|
+
// The 2px between the control line and the annotation block beneath it — the
|
|
251
|
+
// same rhythm `FieldAnnotations` puts between its own lines.
|
|
282
252
|
valueStack: { gap: 2 },
|
|
283
|
-
// Aligns annotation text with the inline chip's text (its 8px inset).
|
|
284
|
-
annotation: { paddingLeft: 8 },
|
|
285
253
|
// Form mode (labelWidth set): the value column FILLS the row so every inline
|
|
286
254
|
// editor spans the SAME width — and none jumps wider when it swaps to the
|
|
287
255
|
// (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { CONTROL_TEXT_INSET } from "./control_surface";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a FIELD says about itself under its own value — ONE anatomy, shared by every field
|
|
7
|
+
* surface in the kit (`DetailRow` on a record, `TaskSubRow` on a task). A record's field and a
|
|
8
|
+
* task's field carry the same three tones, in the same order, with the same a11y, because they
|
|
9
|
+
* ARE the same thing on two surfaces: a name, a value the reader sets, and whatever has to be
|
|
10
|
+
* said about it. Where the two disagreed, an app with a field-level fault had to demote it into
|
|
11
|
+
* prose about the whole ROW — which says something different.
|
|
12
|
+
*
|
|
13
|
+
* Always VISIBLE. A field annotation is never a hidden ⓘ gloss: guidance nobody opens is
|
|
14
|
+
* guidance nobody reads, and a fault behind a disclosure is a fault the reader ships.
|
|
15
|
+
*/
|
|
16
|
+
export interface FieldAnnotationProps {
|
|
17
|
+
/** PERSISTENT guidance — what to enter, or the rule that applies ("Printed on every
|
|
18
|
+
* document"). Muted, never announced: it is true before the reader touches the field and
|
|
19
|
+
* stays true after. An invalid STATE is `error`, not a description written in the past
|
|
20
|
+
* tense. Same name, same meaning as `FormField.description`. */
|
|
21
|
+
description?: string;
|
|
22
|
+
/** A consequence the reader should WEIGH before acting — amber, one tone between the muted
|
|
23
|
+
* `description` and the danger `error`, announced on appearance. It does not block: the
|
|
24
|
+
* value is accepted and something downstream is worse for it. Same name, same meaning as
|
|
25
|
+
* `FormField.warning`. */
|
|
26
|
+
warning?: string;
|
|
27
|
+
/** Field-level FAILURE — danger ink, `FormField`'s alert semantics, announced on appearance.
|
|
28
|
+
* For CONSUMER-validated state: the `Inline*` editors already render their own transient
|
|
29
|
+
* save errors, so don't wire both to one failure. */
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Does this field have anything to say? Hosts ask because an annotated field is a TALLER row,
|
|
34
|
+
* and the label beside it still has to centre on the CONTROL line rather than on the block. */
|
|
35
|
+
export function hasFieldAnnotation(props: FieldAnnotationProps): boolean {
|
|
36
|
+
return props.description != null || props.warning != null || props.error != null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The stack itself — rendered directly beneath the value, `null` when the field has nothing to
|
|
41
|
+
* say.
|
|
42
|
+
*
|
|
43
|
+
* SEVERITY DESCENDS FROM THE CONTROL: error first (adjacent to the value that failed), then
|
|
44
|
+
* warning, then the guidance. All three can be present at once and none suppresses another —
|
|
45
|
+
* they answer different questions ("this is broken" / "this will cost you" / "this is what the
|
|
46
|
+
* field is for"), and hiding the rule that explains the failure is exactly the wrong economy.
|
|
47
|
+
*/
|
|
48
|
+
export function FieldAnnotations(props: FieldAnnotationProps) {
|
|
49
|
+
if (!hasFieldAnnotation(props)) return null;
|
|
50
|
+
return (
|
|
51
|
+
<View style={styles.stack}>
|
|
52
|
+
{props.error != null ? (
|
|
53
|
+
<Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
|
|
54
|
+
{props.error}
|
|
55
|
+
</Text>
|
|
56
|
+
) : null}
|
|
57
|
+
{props.warning != null ? (
|
|
58
|
+
<Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite">
|
|
59
|
+
{props.warning}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
{props.description != null ? (
|
|
63
|
+
<Text size="xs" color="muted">
|
|
64
|
+
{props.description}
|
|
65
|
+
</Text>
|
|
66
|
+
) : null}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
// The control's own text inset, so an annotation lines up with the value's WORDS rather than
|
|
73
|
+
// with the editor's invisible box — and the 2px that separates the lines from each other,
|
|
74
|
+
// which is the same rhythm the host puts between the control and this block.
|
|
75
|
+
stack: { gap: 2, paddingLeft: CONTROL_TEXT_INSET },
|
|
76
|
+
});
|
package/src/progress_bar.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
44
|
-
|
|
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(
|
|
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(
|
|
78
|
+
? `${value.toLocaleString(localeTag)} / ${max.toLocaleString(localeTag)} · ${Math.round(ratio)}%`
|
|
66
79
|
: format === "percentage"
|
|
67
|
-
? `${Math.round(
|
|
80
|
+
? `${Math.round(ratio)}%`
|
|
68
81
|
: null;
|
|
69
82
|
|
|
70
83
|
return (
|
package/src/section_heading.tsx
CHANGED
|
@@ -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
|
-
|
|
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}
|
package/src/spreadsheet_view.tsx
CHANGED
|
@@ -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
|
|
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/task.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react";
|
|
2
2
|
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { colors } from "./colors";
|
|
4
|
+
import { CONTROL_TEXT_INSET } from "./control_surface";
|
|
4
5
|
import { DETAIL_LABEL_WIDTH } from "./detail_row";
|
|
6
|
+
import { FieldAnnotationProps, FieldAnnotations } from "./field_annotations";
|
|
5
7
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
6
8
|
import { Text } from "./text";
|
|
7
9
|
import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_metrics";
|
|
@@ -51,6 +53,14 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
|
|
|
51
53
|
* `TaskDetail` is the exception that proves the indent — a free-form block has no label to hang
|
|
52
54
|
* on it, so it keeps a rule instead.
|
|
53
55
|
*
|
|
56
|
+
* **The sub-row LABEL column is the list's** (`labelWidth`, defaulting to the record surface's
|
|
57
|
+
* `DETAIL_LABEL_WIDTH`), inherited to every depth like the gutters. A name longer than it wraps
|
|
58
|
+
* INSIDE it and its value stays put. The column each sub-row sized to its OWN words looked
|
|
59
|
+
* shared on a list of two-word English names and was not one: a short label started its value at
|
|
60
|
+
* one x, a long one at another, and a vocabulary with longer words than English staggered nearly
|
|
61
|
+
* every row. A column that is not shared is not a column — and this is the label's, never the
|
|
62
|
+
* value's, which is the one this family deleted.
|
|
63
|
+
*
|
|
54
64
|
* **A task list is not a table.** Anything the reader scans DOWN a column — six rows compared on
|
|
55
65
|
* the same four values — is a `Table` or a `DataGrid`, which are built for it. A `TaskList` sized
|
|
56
66
|
* for that is a table with a worse header.
|
|
@@ -70,10 +80,14 @@ import { TASK_ROW_BAND, TASK_ROW_GAP, taskGutter, taskTitleSlack } from "./task_
|
|
|
70
80
|
*
|
|
71
81
|
* **A value and a sentence are different things.** A `TaskSubRow` holds a NAMED value the reader
|
|
72
82
|
* sets; `TaskCaption` holds a sentence ABOUT the row ("Missing 2 of 6") on its own line under the
|
|
73
|
-
* title. Prose given a label reads as a field nobody can edit.
|
|
83
|
+
* title. Prose given a label reads as a field nobody can edit. Which is why a sub-row annotates
|
|
84
|
+
* like a record's field does — `description` · `warning` · `error`, the one anatomy in
|
|
85
|
+
* `field_annotations` — so a fault in ONE field says so on that field instead of being demoted
|
|
86
|
+
* into a sentence about the whole row.
|
|
74
87
|
*
|
|
75
88
|
* There is deliberately no `note` slot. A task's free text is its title, a caption, or detail;
|
|
76
|
-
* a fourth place to write invited writing it twice.
|
|
89
|
+
* a fourth place to write about the TASK invited writing it twice. A sentence about one FIELD is
|
|
90
|
+
* that sub-row's own annotation, not a caption.
|
|
77
91
|
*/
|
|
78
92
|
|
|
79
93
|
interface TaskListContextValue {
|
|
@@ -81,6 +95,8 @@ interface TaskListContextValue {
|
|
|
81
95
|
controlWidth: number;
|
|
82
96
|
/** The RIGHT gutter's control — the row's ⋯. `0` on a list whose rows carry no actions. */
|
|
83
97
|
actionWidth: number;
|
|
98
|
+
/** The label column every `TaskSubRow` on the list renders its name in, at every depth. */
|
|
99
|
+
labelWidth: number;
|
|
84
100
|
/** 0 at the root. Any value above 0 means "I am rendered inside a task". */
|
|
85
101
|
depth: number;
|
|
86
102
|
density: TaskDensity;
|
|
@@ -101,29 +117,11 @@ const TASK_ACTION_WIDTH = 28;
|
|
|
101
117
|
const TaskListContext = createContext<TaskListContextValue>({
|
|
102
118
|
controlWidth: 20,
|
|
103
119
|
actionWidth: TASK_ACTION_WIDTH,
|
|
120
|
+
labelWidth: DETAIL_LABEL_WIDTH,
|
|
104
121
|
depth: 0,
|
|
105
122
|
density: "comfortable",
|
|
106
123
|
});
|
|
107
124
|
|
|
108
|
-
/**
|
|
109
|
-
* A sub-row's label column. It is sized to its OWN content — no measurement, no width from the
|
|
110
|
-
* list — between a floor and a ceiling, which is what makes the labels of ONE task line up with
|
|
111
|
-
* each other: a field name is two or three words, every one of them lands under the floor, so
|
|
112
|
-
* every value in that task starts on the same x. The floor IS the kit's label column
|
|
113
|
-
* (`DETAIL_LABEL_WIDTH`, `DetailTable`'s default) — ~18 characters, where real field names live
|
|
114
|
-
* ("Completed on", "Portal account", "Courier reference") — taken from that one constant rather
|
|
115
|
-
* than matched to it, so a task's fields and a record's cannot drift apart on a surface carrying
|
|
116
|
-
* both.
|
|
117
|
-
*
|
|
118
|
-
* The ceiling is the honest half of the deal. A label longer than the floor takes the width it
|
|
119
|
-
* needs and its value sits outboard of its siblings' — until the ceiling, where it wraps instead,
|
|
120
|
-
* so one verbose name cannot push the whole column off the surface. A TRUE shared column (every
|
|
121
|
-
* label at the widest sibling's width, whatever that is) cannot be had in flexbox without
|
|
122
|
-
* measuring text, and a measured column costs a layout pass, a re-render and a rule this file
|
|
123
|
-
* could not state — for a case that mostly does not arise.
|
|
124
|
-
*/
|
|
125
|
-
const SUB_LABEL_MIN = DETAIL_LABEL_WIDTH;
|
|
126
|
-
const SUB_LABEL_MAX = 168;
|
|
127
125
|
/**
|
|
128
126
|
* The width a value claims before it would rather WRAP. It is set by what a real value needs —
|
|
129
127
|
* an email address, a member's name, a three-word status all run to about 185px — so below it,
|
|
@@ -133,17 +131,14 @@ const SUB_LABEL_MAX = 168;
|
|
|
133
131
|
*/
|
|
134
132
|
const SUB_VALUE_MIN = 200;
|
|
135
133
|
/**
|
|
136
|
-
* How far a `variant="cell"` control insets its own text
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* `variant="cell"` control has to add it, or
|
|
141
|
-
* around it.
|
|
142
|
-
*
|
|
143
|
-
* It was 8 — the padding alone, forgetting the border — which put every caption, detail block
|
|
144
|
-
* and nested list 1px left of the title it belonged to.
|
|
134
|
+
* How far a `variant="cell"` control insets its own text — the kit's `CONTROL_TEXT_INSET` under
|
|
135
|
+
* this family's name, because a task surface reaches for it constantly. Everything that hangs
|
|
136
|
+
* beneath a row — the caption, a sub-row's label, the detail block — adds the same, so it lines
|
|
137
|
+
* up with the WORDS rather than with the editor's invisible box. A STRING title gets it from
|
|
138
|
+
* `TaskTitle`; only a custom title NODE that is not a `variant="cell"` control has to add it, or
|
|
139
|
+
* it sits out of line with the editable titles around it.
|
|
145
140
|
*/
|
|
146
|
-
export const TASK_TEXT_INSET =
|
|
141
|
+
export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
|
|
147
142
|
|
|
148
143
|
export interface TaskListProps {
|
|
149
144
|
children: ReactNode;
|
|
@@ -163,6 +158,20 @@ export interface TaskListProps {
|
|
|
163
158
|
* nested list inherits it.
|
|
164
159
|
*/
|
|
165
160
|
actionWidth?: number;
|
|
161
|
+
/**
|
|
162
|
+
* The width of the label column EVERY `TaskSubRow` on this list renders its name in, at every
|
|
163
|
+
* depth — the list's, never the row's, exactly as `DetailTable labelWidth` is the record's.
|
|
164
|
+
* Defaults to `DETAIL_LABEL_WIDTH` (130, ~18 characters), taken from that one constant rather
|
|
165
|
+
* than matched to it, so a task's fields and a record's cannot drift apart on a surface
|
|
166
|
+
* carrying both.
|
|
167
|
+
*
|
|
168
|
+
* A name longer than the column WRAPS inside it and its value stays put. That is the whole
|
|
169
|
+
* point: a column each row sized to its OWN words is not a column — it started a short label's
|
|
170
|
+
* value at one x and a long one's at another, on the same list, and a surface whose field
|
|
171
|
+
* names run long (any language with longer words than English) had a different value x on
|
|
172
|
+
* nearly every row. Raise it for a vocabulary that genuinely needs more, on the LIST, once.
|
|
173
|
+
*/
|
|
174
|
+
labelWidth?: number;
|
|
166
175
|
/**
|
|
167
176
|
* `comfortable` (the default) gives every row a 44px first-line BAND — the tap target a
|
|
168
177
|
* finger needs, and what the leading control centres in; `dense` drops to 32px for a
|
|
@@ -183,9 +192,10 @@ export function TaskList(props: TaskListProps) {
|
|
|
183
192
|
const isNested = parent.depth > 0;
|
|
184
193
|
const controlWidth = props.controlWidth ?? parent.controlWidth;
|
|
185
194
|
const actionWidth = props.actionWidth ?? parent.actionWidth;
|
|
195
|
+
const labelWidth = props.labelWidth ?? parent.labelWidth;
|
|
186
196
|
const density = props.density ?? parent.density;
|
|
187
197
|
return (
|
|
188
|
-
<TaskListContext.Provider value={{ controlWidth, actionWidth, depth: parent.depth + 1, density }}>
|
|
198
|
+
<TaskListContext.Provider value={{ controlWidth, actionWidth, labelWidth, depth: parent.depth + 1, density }}>
|
|
189
199
|
<View
|
|
190
200
|
style={[
|
|
191
201
|
styles.list,
|
|
@@ -344,32 +354,40 @@ export function TaskActions(props: { children: ReactNode }) {
|
|
|
344
354
|
);
|
|
345
355
|
}
|
|
346
356
|
|
|
357
|
+
interface TaskSubRowProps extends FieldAnnotationProps {
|
|
358
|
+
/** The field's NAME, rendered muted in the LIST's label column. Longer than the column and it
|
|
359
|
+
* WRAPS inside it — it is never clipped, and it never moves its own value. */
|
|
360
|
+
label: ReactNode;
|
|
361
|
+
/** The value's control — a form-variant `Inline*` editor, a `Text`. It FILLS the cell. */
|
|
362
|
+
children: ReactNode;
|
|
363
|
+
}
|
|
364
|
+
|
|
347
365
|
/**
|
|
348
366
|
* ONE of the task's own FIELDS, hung under the row: its NAME, then its VALUE, adjacent, indented
|
|
349
367
|
* one step so it reads as the task's.
|
|
350
368
|
*
|
|
351
|
-
* The
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
369
|
+
* **The label column belongs to the LIST** (`TaskList labelWidth`, defaulting to the kit's
|
|
370
|
+
* `DETAIL_LABEL_WIDTH`), so every sub-row on the surface starts its value on ONE x, at every
|
|
371
|
+
* depth — the same contract `DetailTable labelWidth` gives a record's fields, because a task's
|
|
372
|
+
* field and a record's field are the same thing on two surfaces. A label longer than the column
|
|
373
|
+
* wraps inside it and its value does not move. The column each row sized to its OWN words was
|
|
374
|
+
* the bug: it aligned the labels of a task whose names happened to be short and staggered every
|
|
375
|
+
* other one, and a surface whose field names run long had a different value x on nearly every
|
|
376
|
+
* row. A column that is not shared is not a column.
|
|
356
377
|
*
|
|
357
378
|
* The value takes the row's slack from a readable minimum: on a narrow surface it drops onto its
|
|
358
379
|
* own line under the label rather than compressing to a few characters, and on a wide one it
|
|
359
380
|
* FILLS the content box, ending on the same x as the title above it and the ⋯ gutter beside it.
|
|
360
381
|
*
|
|
382
|
+
* It annotates exactly like a `DetailRow` — `description` · `warning` · `error`, one shared
|
|
383
|
+
* anatomy (`field_annotations`). A field-level fault says so ON THE FIELD; demoting it into a
|
|
384
|
+
* `TaskCaption`, which is a sentence about the ROW, says something different.
|
|
385
|
+
*
|
|
361
386
|
* **Use `TaskDetail` instead when the block is not a label/value field** — a chart, a whole
|
|
362
387
|
* table, a form with its own submit. (Subtasks are neither: they are a nested `TaskList`.)
|
|
363
388
|
*/
|
|
364
|
-
export function TaskSubRow(props: {
|
|
365
|
-
|
|
366
|
-
label: ReactNode;
|
|
367
|
-
/** The value's control — a form-variant `Inline*` editor, a `Text`. It FILLS the cell. */
|
|
368
|
-
children: ReactNode;
|
|
369
|
-
/** Persistent guidance under the value ("The password is never stored"), on the control's
|
|
370
|
-
* own text inset. Not a state: a fault or a warning about the ROW is a `TaskCaption`. */
|
|
371
|
-
description?: string;
|
|
372
|
-
}) {
|
|
389
|
+
export function TaskSubRow(props: TaskSubRowProps) {
|
|
390
|
+
const { labelWidth } = useContext(TaskListContext);
|
|
373
391
|
// The sub-row's own first-line band. The row's band, unless the density's band cannot contain
|
|
374
392
|
// a form control (a `dense` 32 row against a 40px inline editor) — the label's slack and the
|
|
375
393
|
// control's centre are then BOTH taken from 40, so they still meet on one y.
|
|
@@ -379,18 +397,17 @@ export function TaskSubRow(props: {
|
|
|
379
397
|
<View
|
|
380
398
|
style={[
|
|
381
399
|
styles.subLabel,
|
|
382
|
-
//
|
|
383
|
-
// line on the control beside it, the same law
|
|
384
|
-
|
|
400
|
+
// The LIST's column, and symmetric slack rather than centring — a field name long
|
|
401
|
+
// enough to WRAP keeps its first line on the control beside it, the same law
|
|
402
|
+
// `TaskTitle` follows against the ring.
|
|
403
|
+
{ width: labelWidth, paddingVertical: taskTitleSlack(band) },
|
|
385
404
|
]}
|
|
386
405
|
>
|
|
387
406
|
<Text size="sm" color="muted">{props.label}</Text>
|
|
388
407
|
</View>
|
|
389
408
|
<View style={styles.subValue}>
|
|
390
409
|
<View style={[styles.subControl, { minHeight: band }]}>{props.children}</View>
|
|
391
|
-
{props.description
|
|
392
|
-
<Text size="xs" color="muted" style={styles.subDescription}>{props.description}</Text>
|
|
393
|
-
) : null}
|
|
410
|
+
<FieldAnnotations description={props.description} warning={props.warning} error={props.error} />
|
|
394
411
|
</View>
|
|
395
412
|
</View>
|
|
396
413
|
);
|
|
@@ -460,16 +477,18 @@ const styles = StyleSheet.create({
|
|
|
460
477
|
// rhythm the row uses — and the same wrap: too narrow to seat both and the value drops to its
|
|
461
478
|
// own line under the label.
|
|
462
479
|
subRow: { flexBasis: "100%", flexDirection: "row", flexWrap: "wrap", columnGap: TASK_ROW_GAP },
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
// narrower than the
|
|
466
|
-
//
|
|
467
|
-
//
|
|
480
|
+
// A FIXED column, arriving as the list's `labelWidth` — never sized by this row's own words,
|
|
481
|
+
// which is what puts every value on one x. `flexShrink` is the safety valve alone: a
|
|
482
|
+
// container narrower than the whole column has no line to shrink against, so the label gives
|
|
483
|
+
// way rather than overflowing — and it gives way by the same amount on every row of that
|
|
484
|
+
// list. The inset puts its WORDS on the TITLE's x, not one step in: the control gutter
|
|
485
|
+
// already says which rows are tasks and which are their fields, so an indent on top of it
|
|
486
|
+
// only breaks the single left edge every line on the surface otherwise shares. It is INSIDE
|
|
487
|
+
// the column, so the value starts the same distance from the row's edge as a `DetailRow`'s
|
|
488
|
+
// does on a record.
|
|
468
489
|
subLabel: {
|
|
469
490
|
flexGrow: 0,
|
|
470
491
|
flexShrink: 1,
|
|
471
|
-
minWidth: SUB_LABEL_MIN,
|
|
472
|
-
maxWidth: SUB_LABEL_MAX,
|
|
473
492
|
paddingLeft: TASK_TEXT_INSET,
|
|
474
493
|
},
|
|
475
494
|
// FILLS the rest of the row, exactly as `DetailRow`'s value column does — a sub-row is the
|
|
@@ -477,13 +496,12 @@ const styles = StyleSheet.create({
|
|
|
477
496
|
// left the row reading as a fragment floating under a full-width one. A control too wide for
|
|
478
497
|
// its content is that CONTROL's business (and `DetailRow` would have the same complaint), not
|
|
479
498
|
// a reason for this row to end early.
|
|
480
|
-
|
|
499
|
+
// The 2px `gap` is the air between the control line and any annotation block beneath it — the
|
|
500
|
+
// same rhythm `DetailRow` uses, and inert on the rows that say nothing.
|
|
501
|
+
subValue: { flexGrow: 1, flexShrink: 1, flexBasis: SUB_VALUE_MIN, minWidth: 0, gap: 2 },
|
|
481
502
|
// The control CENTRES in the sub-row's band — the label's first line is pinned to the same
|
|
482
503
|
// centre — and stretches to the cell's full width.
|
|
483
504
|
subControl: { justifyContent: "center" },
|
|
484
|
-
// The control's own text inset, so the guidance lines up with the value it describes rather
|
|
485
|
-
// than with the editor's invisible box.
|
|
486
|
-
subDescription: { paddingLeft: TASK_TEXT_INSET, marginTop: 2 },
|
|
487
505
|
// A full line like the rest, except that its inset arrives as a MARGIN (the rule has to sit on
|
|
488
506
|
// the title's text edge, so it cannot be padding). A 100% basis plus that margin is 9px wider
|
|
489
507
|
// than the line, so `flexShrink` gives the 9 back and the block ends on the content edge with
|
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
|
-
}
|