@lotics/ui 44.10.2 → 44.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -4
- package/docs/catalog.md +28 -1
- package/docs/composition.md +35 -0
- package/docs/data_entry.md +9 -0
- package/package.json +3 -1
- package/src/avatar_group.tsx +183 -0
- package/src/avatar_tone.ts +14 -5
- package/src/chip.tsx +41 -10
- package/src/control_surface.ts +7 -0
- package/src/file_badge.tsx +13 -0
- package/src/group_avatar.tsx +19 -2
- package/src/label_tone.ts +67 -0
- package/src/locale.tsx +3 -3
- package/src/select.tsx +14 -1
package/AGENTS.md
CHANGED
|
@@ -15,9 +15,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
|
-
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | **a picker over a select field keeps the field's colours** (`optionPicker` — the hand-rolled `map` to `{value,label}` drops `color`, so a value reads coloured in a register and grey in the editor); Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count (and why "no selection" is a PLACEHOLDER, never an option valued `""` — the picker reads that as nothing chosen and renders down to its chevron), browser-autofill suppression (search controls only), find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW — list + one CTA, `multiple` decides add-vs-replace; the `FilesEditor` COMPOUND for a whole section — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, whether a multiline value keeps its fixed reserve or `autoGrow`s (who decides the length — the field, or whoever is typing), the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values (machine → a gate: a diff when something is being replaced, a full editable preview when records are being created from a document; human-typed → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; **after the run** — a stored record that fills up from several writers (a person, a chat agent, an extraction, an automation) and remembers none of them: an unwritten value must not render like a written one, a machine's prose and a person's must not share a treatment, model markdown goes in `variant="embedded"`, and the read path must project every field the write paths set; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md), the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer, **Stop** while streaming), **stopping** (`cancel` stops the run, `abort` only stops listening — so closing a dialog must `cancel` or it keeps billing); **review surfaces compose from atoms** — `DiffValue` (a changed value, droppable in any cell/row/total), `DiffMark` (what happened to the row — ONE circular disc, every surface, its kind derived from before/after rather than a boolean "did this change" — an empty `before` is `added`, never `changed`), `useChangeSet` (accept/reject/undo bookkeeping, no layout) — see [MIGRATION.md](./MIGRATION.md) for the `ChangeReview` family they replace |
|
|
20
|
-
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
20
|
+
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS — register, rail, board, timeline, deviation queue — before reaching for parts; a subject flattened into rows is well-typed and says nothing, and reuse-first is about not duplicating primitives, not about a screen's shape; **a hierarchy in the DATA does not oblige one in the UI** — who MAINTAINS the structure decides, and a set the reader scans is a register whatever shape the data has), canvas + content column, heading altitude (incl. eyebrow vs group lead — a label is one or the other, and a section heading's own row never wraps so a control label that repeats the title can squeeze the title+description into a narrow ribbon at phone width), banded cards, register vs inset rows (incl. the register laws a row centres its cells by: every cell a FIXED height, a pressable cell on the shared hover token, a column sized by what it carries), the button ladder and **what underlined text may mean** (it GOES somewhere or REVEALS something — never mutates; blue leaves the surface, muted stays on it, and the one in-prose disclosure exception is scoped there), master-detail `Drawer` on a LIST screen vs a child collection's row EXPANDING inside a record, view controls, RECORD EXTENT (one page, sections scrolled to and never routed to), **the register's rhythm** (no hairline between rows — their own height and the hover wash separate them; the register's one line is the band capping the columns, because that is the break whitespace cannot state. Density is `TableRow`'s `minHeight`, never a mode), **a register that TRIAGES** (`TableGroup` — titled bands ordered by what needs the reader now, grouped by what implies a different ACTION rather than by a category already in a column, and what the screen can CLASSIFY kept as a separate list from what it RENDERS, so a value omitted on purpose does not resurface under *Unclassified* and one added later does not vanish), **the register's own craft** (a column header treated as CHROME a step lighter than row metadata, sentence case in the table header too, and a supporting line one RUNG BELOW the line it supports rather than a fixed size — plus the half that rule was missing: every SUBJECT on one row starts at the same rung, the subject leads by WEIGHT and its identity mark rather than by size, and whether the supporting line drops a rung is decided by what else separates the pair; plus the two ways a `columns` array and a rendered row disagree — a partial `priority` annotation, and a conditional column whose cell was not hidden with it), **identity marks** (a mark that looks the same on every row carries nothing — `Avatar` derives its hue from the name and takes circle/square for person vs organization; the palette is ordered by contrast, not spectrum, and never paints a person red or amber; a column of faces must not draw an UNBOUNDED audience as faces, and an overlapping stack separates with translucent INK rather than the surface colour, which a row's wash keeps changing out from under), **where the accent goes** ("which of these" — a selected row, an active filter; never DATA and never the focus ring, while a page-scale position marker like an active TAB takes `primary`, the ink the kit already spends on "this is on"; and selection must not share a wash with hover), **theming is an app-level ESCAPE HATCH, not a product surface** (`applyLoticsTheme({ … })` once at boot, before `mount`, writing the `--lotics-*` roles on the document root; nothing in the platform writes a theme, and `apps.theme.color` is the launcher icon only — the five `var()` roles plus the three font rungs ARE the whole mechanism, and wrapping a component cannot replace them because RN-Web writes `fontFamily` per element and background/border do not inherit), color discipline, **typography** (the ramp is DATA in `type_ramp`, one table both platforms derive from; leading and tracking are baked per rung and a hand-set `letterSpacing` is always a second copy of the curve), whitespace, **where a commit sits** (with its fields, never an overlay's footer — but a full-page editor is not a dialog, so a scrolling document keeps its commit pinned), and how to TEST an overlay component (a `Popover`-backed surface never mounts under jsdom). |
|
|
21
21
|
| [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built** — the other docs say what good looks like; this one says how to find out whether you achieved it, because "it looks fine" is the same claim as "it should work" made with the same evidence. Measure, never eyeball: render it, extract computed values, let the table show the defect. Two GATES that outrank every treatment question (**SUBTRACT** — what question does this element answer, and count FACTS not elements; **RIGHT CONTROL** — name the species before treating it), then 10 probes each naming what to COLLECT and the SIGNATURE in the numbers: type inventory (range, singletons, the label/value shrink war), ground+border+radius across siblings, what each RULE separates, gap RATIOS, the state diff (paint may change, geometry may not), alignment drift (incl. a centred child that moves while its container measures identical), the surface walk (open it, press it, read the settled string), the DATA probe (what is really in the table — incl. grepping rendered text for internal ids), the ABSENCE pass that catches "bland", and microcopy. Plus the extraction snippets, what measurement cannot see, and how to act on a finding (fix the CLASS, and never resolve one with "the docs say X"). |
|
|
22
22
|
| [docs/testing.md](./docs/testing.md) | Driving the kit in a browser — the three anatomies where the a11y tree says one thing and a driver must do another: a `PressDoor` row whose named button always intercepts pointer events (by design), portalled overlays that render at the top of the DOM, and custom pointer drag that `dragTo` cannot move. |
|
|
23
23
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget) and the ACTIVITY shape — a communications feed where the row's label is the GIST and the body varies by medium, one anatomy rather than a row type per kind. |
|
|
@@ -27,8 +27,9 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
27
27
|
- **Reuse first; the catalog can lag `src/`.** Before hand-rolling ANY capability, `ls src/` and
|
|
28
28
|
grep for a match — a component in `src/` missing from the catalog is a doc bug to fix, not a
|
|
29
29
|
license to hand-roll.
|
|
30
|
-
- **One canonical component per data role** (member → `MemberChip`,
|
|
31
|
-
`MemberPeek`/`MemberProfileCard`, select → `OptionBadge`,
|
|
30
|
+
- **One canonical component per data role** (member → `MemberChip`, SEVERAL of them →
|
|
31
|
+
`AvatarGroup`, who-is-this → `MemberPeek`/`MemberProfileCard`, select → `OptionBadge`,
|
|
32
|
+
files → `FilePreview` family, …) —
|
|
32
33
|
the catalog's Reach-by-role outranks neighboring code.
|
|
33
34
|
- **Responsive layout measures the CONTAINER, not the screen.** `useContainerSize()` reports the
|
|
34
35
|
nearest `SizeBoundary`; wrap one around any region whose width stops tracking its parent's, and
|
package/docs/catalog.md
CHANGED
|
@@ -597,6 +597,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
597
597
|
and `avatar.web.tsx` implement. It lives in a third module because only one of them is ever
|
|
598
598
|
compiled — `tsc` resolves `./avatar` to the native file while every browser loads the web one,
|
|
599
599
|
so a prop declared on one side typechecks everywhere and silently does nothing in the browser.
|
|
600
|
+
- **`label_tone`** — `labelTone(label)`: one stable `ColorName` per free-text label (a tag, a
|
|
601
|
+
topic, a category), for `Badge`. `avatarTone`'s sibling and the same discipline: pure and
|
|
602
|
+
NEVER STORED, so a set of labels can stay a `text[]` instead of earning a table the moment a
|
|
603
|
+
colour has to be chosen; `red` and `amber` excluded, because a palette that can paint an
|
|
604
|
+
arbitrary noun red makes every red on the screen ambiguous; hue-interleaved so two labels in
|
|
605
|
+
adjacent buckets still look different. Case- and space-folded, matching whatever normalizes the
|
|
606
|
+
set — two spellings of one label must not be two colours. Returns a NAME (not a hex like
|
|
607
|
+
`avatarTone`) because `Badge` owns the shade a dot is drawn at.
|
|
600
608
|
- **`avatar_tone`** — `avatarTone(name)`: the initials ground, one stable hue per name, from a
|
|
601
609
|
contrast-ordered palette that excludes `red` and `amber` (a person is not a warning). Pure and
|
|
602
610
|
never stored, so one person is one colour on every screen. See composition.md §Identity marks.
|
|
@@ -606,6 +614,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
606
614
|
- **`group_avatar`** — `GroupAvatar`: a name's initials in a zinc rounded square (`size` — the
|
|
607
615
|
same rung scale AND the same initials rule as `Avatar`, default `md`); the avatar for
|
|
608
616
|
image-less entities — groups, organizations. A person → `Avatar`/`MemberChip`.
|
|
617
|
+
- **`avatar_group`** — `AvatarGroup`: a SET of parties as overlapping marks — "who is on this" in
|
|
618
|
+
one cell. Takes `{ id, name, kind: "person" | "group", image? }`, shows `max` of them (default
|
|
619
|
+
3) and makes the rest a `+N` that is the LAST MARK IN THE STACK, not loose text beside it.
|
|
620
|
+
`size` is the shared avatar scale with the same `md` default as `Avatar`/`MemberChip`, and the
|
|
621
|
+
marks, the overlap and the count's type all derive from it — no fixed pixels, so a stack at any
|
|
622
|
+
rung is the same object. Three rules it exists to carry: the caller passes only principals it
|
|
623
|
+
can NAME, so the overflow never counts a mark the reader sees is missing; a group is ONE mark
|
|
624
|
+
bearing the group's name, never its members' faces (expanding it states a membership the reader
|
|
625
|
+
may not be entitled to); and the separating hairline is **translucent ink, never the surface
|
|
626
|
+
colour** — a register row washes to `zinc[50]` on hover, `accent_wash` when open and
|
|
627
|
+
`zinc[200]` while pressed, so a ring painted the background is a halo in three of the four
|
|
628
|
+
states, while a dark edge darkens any ground (the trick `FileBadge` already uses). Marks
|
|
629
|
+
`announce` because they stand alone here. INERT — its home is a register row, and a row that
|
|
630
|
+
already presses must not hold a second destination (see `member_peek`). A single person →
|
|
631
|
+
`MemberChip`; a single group → `GroupAvatar`.
|
|
609
632
|
- **`wave_avatar`** — `WaveAvatar`: decorative animated waveform avatar (voice/audit
|
|
610
633
|
history); animates on web, renders a static fallback on native.
|
|
611
634
|
- **`member_chip`** — `MemberChip`: avatar + name; the universal person render. `size` scales
|
|
@@ -916,7 +939,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
916
939
|
differs from the visible text is what breaks label-in-name.
|
|
917
940
|
- **`select`** — `Select`: rich/custom-rendered, single/multi, select-all, chips via
|
|
918
941
|
`renderSelected(item, { remove })` + `searchable` + `allowCustom` — the tag field is just
|
|
919
|
-
a multi Select; opens `OptionList`.
|
|
942
|
+
a multi Select; opens `OptionList`. **Set `searchPlaceholder` whenever `allowCustom` is on**:
|
|
943
|
+
the create row only exists once there is a query, so on an untyped menu the placeholder is the
|
|
944
|
+
only always-visible place that can say a new value is allowed — a tag field reading "Search…"
|
|
945
|
+
tells the reader the vocabulary is closed. `customOptionLabel` names what the create row will
|
|
946
|
+
do, with the value in it. Its trigger is `role="combobox"` (a `<div>`), never
|
|
920
947
|
`role="button"`: the chip box legitimately puts a remove button INSIDE the trigger, and a
|
|
921
948
|
real `<button>` may not contain one. **Any trigger that renders caller-supplied content
|
|
922
949
|
must not take `accessibilityRole="button"`** — react-native-web picks the element from the
|
package/docs/composition.md
CHANGED
|
@@ -18,6 +18,16 @@ should have been a rail is well-typed, well-spaced, correctly aligned and still
|
|
|
18
18
|
order or progress, because a status column reports one word and leaves the reader to know which
|
|
19
19
|
word comes after which.
|
|
20
20
|
|
|
21
|
+
**A hierarchy in the DATA does not oblige a hierarchy in the UI.** A parent id, a materialized
|
|
22
|
+
path, a folder column — each is a real structure, and none of them decides the screen's form. Ask
|
|
23
|
+
what the reader DOES with the set: a population they walk (a chart of accounts, a bill of
|
|
24
|
+
materials, a site → line → cell breakdown) is a tree or a drill; one they scan and search is a
|
|
25
|
+
register, and the path becomes the row's supporting line rather than a place to travel to. The
|
|
26
|
+
distinction is who maintains the structure. Where something else writes it — an importer, an
|
|
27
|
+
integration, an agent — a drill-down makes the reader navigate a shape they did not choose and
|
|
28
|
+
cannot see the whole of, while a flat register with the address on the row costs one line and
|
|
29
|
+
loses nothing. Build the tree when walking it IS the task, not because the column exists.
|
|
30
|
+
|
|
21
31
|
The pull toward a register is structural, not a lapse in taste: `Table`, `DetailRow`,
|
|
22
32
|
`SummaryLine` and `Badge` are the most reachable things here, and they compose into something
|
|
23
33
|
defensible for almost any data. That is exactly why the question has to be asked out loud. **Reuse
|
|
@@ -894,6 +904,12 @@ by what KIND of thing they are, so an avatar takes the same number rather than a
|
|
|
894
904
|
`DialogFooter` default `align="end"`; `DrawerFooter` is right-aligned by construction. Primary
|
|
895
905
|
at the right edge, secondary to its left. A confirm dialog (no fields) is all surface: its
|
|
896
906
|
buttons ARE the dialog, and they belong in the footer.
|
|
907
|
+
- **A full-page editor is not a dialog.** Where the body SCROLLS and one commit governs the whole
|
|
908
|
+
page, that commit stays pinned at the page's foot: there is no dialog for it to read as
|
|
909
|
+
belonging to, and inlining it makes a reader scroll a long document to the end to save an edit
|
|
910
|
+
made at the top. What the page's title row carries instead is the actions on the OBJECT — hand
|
|
911
|
+
it to the assistant, the overflow — so the pinned bar holds the commit alone and the two
|
|
912
|
+
clusters never compete for the same press.
|
|
897
913
|
- **Empty result**: `<EmptyState message hint? icon? action?>` — never a bare muted Text.
|
|
898
914
|
- **Inline status**: `<Callout tone="info|success|warning|error|neutral">` — compound (like
|
|
899
915
|
Card): compose `CalloutTitle`/`CalloutText`/`CalloutActions` inside. `Callout` is INLINE;
|
|
@@ -1461,6 +1477,25 @@ correct.
|
|
|
1461
1477
|
Only rows with genuinely no identity are exempt: a list of amounts, a log of events.
|
|
1462
1478
|
- **The rung follows what the row is ABOUT**: `lg` where the entity is the row's subject, `md`
|
|
1463
1479
|
where it is one field among columns (see `avatar_size`).
|
|
1480
|
+
- **A column of faces must not draw an UNBOUNDED audience as faces.** Where a cell answers "who
|
|
1481
|
+
can reach this", the commonest state is often *everyone* — and three sampled avatars assert
|
|
1482
|
+
something false about the one fact the column exists to be scanned for exceptions in. Draw the
|
|
1483
|
+
unbounded grant as a mark that SAYS so, and people only where the set is genuinely enumerable;
|
|
1484
|
+
cap the faces at a few and let the remainder be a `+N` — as the last mark IN the stack, since a
|
|
1485
|
+
number trailing after the discs reads as a separate fact about the row. Drop anyone the row
|
|
1486
|
+
already names in another column — the same person twice reads as two grants. A group is one
|
|
1487
|
+
mark with the group's name, never its members' faces: expanding it states a membership the
|
|
1488
|
+
reader may not be entitled to. And the faces stay INERT inside a pressable row, per
|
|
1489
|
+
member_peek.tsx.
|
|
1490
|
+
- **An overlapping stack separates with translucent INK, never with the surface colour.** The
|
|
1491
|
+
conventional facepile rings each mark in the page background so the discs read as distinct —
|
|
1492
|
+
which binds the ring to a colour the row does not keep. A register row washes on hover, again
|
|
1493
|
+
when it is the open record, and again while pressed, so a ring painted white is a halo in three
|
|
1494
|
+
of the four states, and they are the states a reader is in when they are actually looking. A
|
|
1495
|
+
translucent dark edge darkens whatever is behind it instead of matching it, so it holds on every
|
|
1496
|
+
ground; `FileBadge` separates itself from its own surface the same way. Use a `boxShadow`, not a
|
|
1497
|
+
border — a border grows the box, and the stacked mark then measures wider than the identical
|
|
1498
|
+
mark in the next column.
|
|
1464
1499
|
|
|
1465
1500
|
The brand did not lose a home when the disc stopped carrying it — it moved to chrome, below.
|
|
1466
1501
|
|
package/docs/data_entry.md
CHANGED
|
@@ -392,6 +392,15 @@ neighbour, and variation in row WEIGHT reads as noise long before row COUNT does
|
|
|
392
392
|
| More options, or long labels, or the value is set once and read after | **`InlineSelect`** | scales to any count; costs two presses (open, pick) and hides the alternatives until you do — put each option's gloss in `renderOptionContent`, where it is needed while CHOOSING rather than on every later read |
|
|
393
393
|
| The choice IS the task — a wizard step, a short focused form | **`RadioPicker`** | full-width choices with their descriptions permanently visible. In a grid of many fields this is ~4 rows of height for one value; never use it as a row in a record's field table |
|
|
394
394
|
|
|
395
|
+
**"No selection" is the PLACEHOLDER, never an option whose value is the empty string.** A picker
|
|
396
|
+
reads `""` as *nothing chosen* — it is what its own clear affordance emits back through
|
|
397
|
+
`onValueChange`, so the two readings cannot both hold. Model the unfiltered/unset state as an
|
|
398
|
+
ABSENT value (`value={x ?? undefined}` + `placeholder`), and map an incoming `""` back to that
|
|
399
|
+
absence at the call site. An `{ value: "", label: "All types" }` option instead leaves the control
|
|
400
|
+
with no selection AND no placeholder, so the trigger renders down to its chevron — a filter that
|
|
401
|
+
still works, sized at a few pixels, which reviews as a rendering glitch rather than a modelling
|
|
402
|
+
error.
|
|
403
|
+
|
|
395
404
|
**`SegmentedControl` is NOT a field control.** It belongs to the view-control vocabulary (a mode
|
|
396
405
|
or parameter of the SAME view — see the composition grammar), and it fails as a value editor for
|
|
397
406
|
two concrete reasons: it is a bounded TRACK, so in a value column that fills the row it either
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "44.
|
|
3
|
+
"version": "44.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -245,6 +245,8 @@
|
|
|
245
245
|
"./tabs": "./src/tabs.tsx",
|
|
246
246
|
"./segmented_control": "./src/segmented_control.tsx",
|
|
247
247
|
"./auto_sizer": "./src/auto_sizer.tsx",
|
|
248
|
+
"./avatar_group": "./src/avatar_group.tsx",
|
|
249
|
+
"./label_tone": "./src/label_tone.ts",
|
|
248
250
|
"./group_avatar": "./src/group_avatar.tsx",
|
|
249
251
|
"./page_content": "./src/page_content.tsx",
|
|
250
252
|
"./page_header": "./src/page_header.tsx",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
import { Avatar } from "./avatar";
|
|
3
|
+
import { GroupAvatar } from "./group_avatar";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { AVATAR_PX, AVATAR_TEXT, type AvatarSize } from "./avatar_size";
|
|
6
|
+
import { colors } from "./colors";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
8
|
+
|
|
9
|
+
/** One party in the set. A PERSON takes the disc; a group or an organization
|
|
10
|
+
* takes the rounded square, which is `Avatar`'s own shape axis and the one
|
|
11
|
+
* thing that tells a reader "this is a team, not a colleague". */
|
|
12
|
+
export interface AvatarGroupPrincipal {
|
|
13
|
+
/** Stable identity — the React key. Never the display name: two colleagues
|
|
14
|
+
* share a name far more often than they share an id. */
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
kind: "person" | "group";
|
|
18
|
+
/** A person's image; absent falls back to initials. Ignored for a group,
|
|
19
|
+
* which has no likeness of its own. */
|
|
20
|
+
image?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How many marks show before the rest become a count, when the caller does not
|
|
24
|
+
* say. Three reads as "a few people"; past that the initials stop being
|
|
25
|
+
* separable at a glance and a number is the more honest summary. */
|
|
26
|
+
const DEFAULT_MAX = 3;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How far each mark tucks under the one before it, as a fraction of the rung.
|
|
30
|
+
*
|
|
31
|
+
* A FRACTION, not a pixel constant, so the stack is the same object at every
|
|
32
|
+
* rung — a fixed inset barely stacks at `lg` and eats the marks at `sm`. Just
|
|
33
|
+
* under a third leaves each mark's whole leading edge, which is as much of an
|
|
34
|
+
* image or a pair of initials as the reader needs to tell the parties apart.
|
|
35
|
+
*/
|
|
36
|
+
const OVERLAP_RATIO = 0.3;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The hairline separating each mark from the one it covers.
|
|
40
|
+
*
|
|
41
|
+
* TRANSLUCENT INK, never the surface colour — and that is what lets this
|
|
42
|
+
* component overlap at all. The conventional facepile rings each mark in the
|
|
43
|
+
* page background so the discs read as separate, which binds the ring to a
|
|
44
|
+
* colour the row does not keep: a register row washes to `zinc[50]` on hover,
|
|
45
|
+
* `accent_wash` as the open record and `zinc[200]` while pressed, so a white
|
|
46
|
+
* ring is a halo in three of the four states — and those are the states a
|
|
47
|
+
* reader is in when they are actually looking at the row. A translucent dark
|
|
48
|
+
* edge DARKENS whatever is behind it instead of matching it, so it holds on
|
|
49
|
+
* every ground. `FileBadge` separates itself from its own surface the same way.
|
|
50
|
+
*
|
|
51
|
+
* A `boxShadow` rather than a border, because a border grows the box: a stacked
|
|
52
|
+
* mark would render 2px wider than the identical mark in the next column.
|
|
53
|
+
*/
|
|
54
|
+
const MARK_RING = "0 0 0 1px rgba(0,0,0,0.12)";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A SET of parties as marks — the answer to "who is on this" in one cell.
|
|
58
|
+
*
|
|
59
|
+
* The rules that make this a component rather than a `.map()`, each of which a
|
|
60
|
+
* hand-rolled version gets wrong on its own:
|
|
61
|
+
*
|
|
62
|
+
* **It resolves before it caps.** A principal the caller could not name is
|
|
63
|
+
* dropped by the CALLER (pass only what you can name) — but the overflow is
|
|
64
|
+
* counted from what will actually render, so `+2` never counts a mark the
|
|
65
|
+
* reader can see is missing. Slicing first and rendering a blank for the
|
|
66
|
+
* unnameable ones is the bug this ordering exists to prevent.
|
|
67
|
+
*
|
|
68
|
+
* **A group is ONE mark bearing the group's NAME**, never its members' faces.
|
|
69
|
+
* Expanding a team into people states a membership the reader may not be
|
|
70
|
+
* entitled to, and turns one grant into several.
|
|
71
|
+
*
|
|
72
|
+
* **The marks OVERLAP, and the remainder is the last mark in the stack.** The
|
|
73
|
+
* set reads as one object rather than as N things that happen to be adjacent,
|
|
74
|
+
* and a `+2` trailing after the discs as loose text reads as a separate fact
|
|
75
|
+
* about the row instead of as the rest of this one. The first mark sits on top
|
|
76
|
+
* and each one after tucks beneath it, so scanning left to right crosses whole
|
|
77
|
+
* leading edges.
|
|
78
|
+
*
|
|
79
|
+
* **Every mark ANNOUNCES itself.** `Avatar` and `GroupAvatar` are decorative by
|
|
80
|
+
* default, because a mark usually sits beside the name in visible text and
|
|
81
|
+
* announcing both reads it twice. Here there is no name text anywhere — the
|
|
82
|
+
* marks ARE the content — so a decorative default would leave a screen reader
|
|
83
|
+
* with nothing but a `+2`.
|
|
84
|
+
*
|
|
85
|
+
* **It is INERT.** No peek, no press. Its home is a register row, and a row
|
|
86
|
+
* that already presses must not hold a second destination four pixels to the
|
|
87
|
+
* left (`member_peek.tsx` has the long version).
|
|
88
|
+
*
|
|
89
|
+
* Pure, like every mark in the family: pass resolved names and images in — this
|
|
90
|
+
* fetches nothing and knows no directory.
|
|
91
|
+
*/
|
|
92
|
+
export interface AvatarGroupProps {
|
|
93
|
+
principals: readonly AvatarGroupPrincipal[];
|
|
94
|
+
/** Marks shown before the remainder becomes `+N`. Default 3. */
|
|
95
|
+
max?: number;
|
|
96
|
+
/**
|
|
97
|
+
* A rung on the shared avatar scale — the SAME scale and the SAME default
|
|
98
|
+
* (`md`) as `Avatar`, `MemberChip` and `GroupAvatar`, because a set of people
|
|
99
|
+
* in one column is the same kind of thing as the one person in the next.
|
|
100
|
+
*
|
|
101
|
+
* Everything inside derives from it: the marks, how far they overlap, and the
|
|
102
|
+
* remainder's type. Nothing here is a fixed pixel, so a stack at `lg` is the
|
|
103
|
+
* same object as a stack at `sm` rather than a differently-proportioned one.
|
|
104
|
+
*/
|
|
105
|
+
size?: AvatarSize;
|
|
106
|
+
style?: StyleProp<ViewStyle>;
|
|
107
|
+
testID?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function AvatarGroup(props: AvatarGroupProps) {
|
|
111
|
+
const { principals, max = DEFAULT_MAX, size = "md", style, testID } = props;
|
|
112
|
+
const locale = useLoticsLocale();
|
|
113
|
+
|
|
114
|
+
if (principals.length === 0) return null;
|
|
115
|
+
|
|
116
|
+
const shown = principals.slice(0, max);
|
|
117
|
+
const hidden = principals.length - shown.length;
|
|
118
|
+
const px = AVATAR_PX[size];
|
|
119
|
+
const overlap = Math.round(px * OVERLAP_RATIO);
|
|
120
|
+
// Descending, so the FIRST mark ends up on top. The remainder is one more
|
|
121
|
+
// thing to lay under, hence its own slot in the depth.
|
|
122
|
+
const depth = shown.length + (hidden > 0 ? 1 : 0);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View testID={testID} style={[{ flexDirection: "row", alignItems: "center" }, style]}>
|
|
126
|
+
{shown.map((principal, index) => (
|
|
127
|
+
<View
|
|
128
|
+
key={principal.id}
|
|
129
|
+
style={{
|
|
130
|
+
marginLeft: index === 0 ? 0 : -overlap,
|
|
131
|
+
// `zIndex` needs a positioned box to take effect in
|
|
132
|
+
// react-native-web; without it the marks stack in DOM order and the
|
|
133
|
+
// REMAINDER ends up covering a person.
|
|
134
|
+
position: "relative",
|
|
135
|
+
zIndex: depth - index,
|
|
136
|
+
// The ring follows the mark's own silhouette — a square team mark
|
|
137
|
+
// ringed as a circle shows the ring cutting its corners off.
|
|
138
|
+
borderRadius: principal.kind === "group" ? px * 0.2 : px / 2,
|
|
139
|
+
boxShadow: MARK_RING,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{principal.kind === "group" ? (
|
|
143
|
+
<GroupAvatar name={principal.name} size={size} announce />
|
|
144
|
+
) : (
|
|
145
|
+
<Avatar
|
|
146
|
+
size={size}
|
|
147
|
+
name={principal.name}
|
|
148
|
+
announce
|
|
149
|
+
source={principal.image ? { uri: principal.image } : undefined}
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
</View>
|
|
153
|
+
))}
|
|
154
|
+
{hidden > 0 && (
|
|
155
|
+
<View
|
|
156
|
+
accessible
|
|
157
|
+
accessibilityLabel={locale.avatar.more(hidden)}
|
|
158
|
+
style={{
|
|
159
|
+
width: px,
|
|
160
|
+
height: px,
|
|
161
|
+
marginLeft: shown.length === 0 ? 0 : -overlap,
|
|
162
|
+
position: "relative",
|
|
163
|
+
zIndex: 0,
|
|
164
|
+
borderRadius: px / 2,
|
|
165
|
+
// A ground of its own, and a NEUTRAL one: the remainder is the only
|
|
166
|
+
// mark in the stack that is not somebody, so it must not wear a
|
|
167
|
+
// colour the tone scale hands to people.
|
|
168
|
+
backgroundColor: colors.zinc["100"],
|
|
169
|
+
alignItems: "center",
|
|
170
|
+
justifyContent: "center",
|
|
171
|
+
boxShadow: MARK_RING,
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{/* The rung's own type — the same one initials take. A fixed size here
|
|
175
|
+
is how a stack ends up with a 14px count beside 12px initials. */}
|
|
176
|
+
<Text size={AVATAR_TEXT[size]} weight="medium" color="zinc-700" aria-hidden>
|
|
177
|
+
{`+${hidden}`}
|
|
178
|
+
</Text>
|
|
179
|
+
</View>
|
|
180
|
+
)}
|
|
181
|
+
</View>
|
|
182
|
+
);
|
|
183
|
+
}
|
package/src/avatar_tone.ts
CHANGED
|
@@ -52,11 +52,20 @@ const IDENTITY_TONES: readonly string[] = [
|
|
|
52
52
|
* The ground for `name`'s initials — stable for a given string, so one person
|
|
53
53
|
* keeps one colour across every screen and every session.
|
|
54
54
|
*
|
|
55
|
-
* The hash MIXES rather than sums, which
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
55
|
+
* The hash MIXES rather than sums, which makes POSITION count: a plain character
|
|
56
|
+
* sum gives `Hai Dang` and `Dang Hai` the same disc, and reorderings are common
|
|
57
|
+
* in names (family name first or last, with or without a middle name).
|
|
58
|
+
* Multiplying by 31 per character separates most of them; `|0` keeps it in int32
|
|
59
|
+
* so the result is identical on every engine.
|
|
60
|
+
*
|
|
61
|
+
* It does NOT fix anagrams in general, and the claim used to read that it did.
|
|
62
|
+
* Swapping two adjacent characters changes the hash by a multiple of 30, and
|
|
63
|
+
* this palette has ten entries — 30 mod 10 is 0, so every two-character
|
|
64
|
+
* transposition still lands in the same bucket. Nothing is broken by that: ten
|
|
65
|
+
* buckets means one name pair in ten collides anyway, and the guarantee worth
|
|
66
|
+
* having is a SPREAD across a real roster, not distinctness per pair. Stated
|
|
67
|
+
* because a reader who believes the stronger claim will write a test asserting
|
|
68
|
+
* it, and the test will be wrong rather than the code.
|
|
60
69
|
*
|
|
61
70
|
* It is NOT what fixed the clustering seen on a real register — the sum was
|
|
62
71
|
* already producing a healthy spread of buckets there, and the interleaved order
|
package/src/chip.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { Ref } from "react";
|
|
|
2
2
|
import { IconButton } from "./icon_button";
|
|
3
3
|
import { useLoticsLocale } from "./locale";
|
|
4
4
|
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
-
import { chipSurfaceStyle } from "./control_surface";
|
|
5
|
+
import { chipSurfaceStyle, CONTROL_HEIGHT } from "./control_surface";
|
|
6
6
|
import { StyleSheet, View } from "react-native";
|
|
7
7
|
|
|
8
8
|
interface ChipProps {
|
|
@@ -18,6 +18,26 @@ interface ChipProps {
|
|
|
18
18
|
ref?: Ref<View>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The dismiss ✕ overlays the pill rather than sitting in its row, because the
|
|
23
|
+
* pill itself may be a `PressableHighlight` and a button cannot contain a
|
|
24
|
+
* button. That makes its geometry a function of the pill's HEIGHT, and it is
|
|
25
|
+
* derived from `CONTROL_HEIGHT` rather than written as four literals — a brief
|
|
26
|
+
* experiment with a shorter chip put a 36px button in a 28px pill and reserved
|
|
27
|
+
* 38px for it, which is what four independent magic numbers buy you.
|
|
28
|
+
*/
|
|
29
|
+
const DISMISS = (() => {
|
|
30
|
+
const iconBox = 28; // IconButton `md`
|
|
31
|
+
const wrapperPad = (CONTROL_HEIGHT - iconBox) / 2 - 2;
|
|
32
|
+
const box = iconBox + wrapperPad * 2;
|
|
33
|
+
const inset = (CONTROL_HEIGHT - box) / 2;
|
|
34
|
+
// `box + inset`, not `box + 2 * inset`: content must stop where the button
|
|
35
|
+
// STARTS. This reproduces the 38 that used to be hardcoded here, so no chip
|
|
36
|
+
// moves — the point of deriving it is that it follows `CONTROL_HEIGHT` if
|
|
37
|
+
// that ever changes, not that the number is new.
|
|
38
|
+
return { wrapperPad, inset, reservedRight: box + inset };
|
|
39
|
+
})();
|
|
40
|
+
|
|
21
41
|
export function Chip(props: ChipProps) {
|
|
22
42
|
const { testID, children, onPress, accessibilityLabel, onDismiss, dismissTooltip, ref } = props;
|
|
23
43
|
const labels = useLoticsLocale().chip;
|
|
@@ -31,15 +51,32 @@ export function Chip(props: ChipProps) {
|
|
|
31
51
|
accessibilityRole="button"
|
|
32
52
|
accessibilityLabel={accessibilityLabel}
|
|
33
53
|
onPress={onPress}
|
|
34
|
-
style={(state) => [
|
|
54
|
+
style={(state) => [
|
|
55
|
+
chipSurfaceStyle(state),
|
|
56
|
+
styles.pillLayout,
|
|
57
|
+
onDismiss && { paddingRight: DISMISS.reservedRight },
|
|
58
|
+
]}
|
|
35
59
|
>
|
|
36
60
|
{children}
|
|
37
61
|
</PressableHighlight>
|
|
38
62
|
) : (
|
|
39
|
-
<View
|
|
63
|
+
<View
|
|
64
|
+
style={[
|
|
65
|
+
chipSurfaceStyle({}),
|
|
66
|
+
styles.pillLayout,
|
|
67
|
+
onDismiss && { paddingRight: DISMISS.reservedRight },
|
|
68
|
+
]}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</View>
|
|
40
72
|
)}
|
|
41
73
|
{onDismiss && (
|
|
42
|
-
<View
|
|
74
|
+
<View
|
|
75
|
+
style={[
|
|
76
|
+
styles.dismissButton,
|
|
77
|
+
{ top: DISMISS.inset, right: DISMISS.inset, padding: DISMISS.wrapperPad },
|
|
78
|
+
]}
|
|
79
|
+
>
|
|
43
80
|
<IconButton
|
|
44
81
|
icon="x"
|
|
45
82
|
tooltip={dismissTooltip}
|
|
@@ -62,14 +99,8 @@ const styles = StyleSheet.create({
|
|
|
62
99
|
gap: 4,
|
|
63
100
|
paddingHorizontal: 12,
|
|
64
101
|
},
|
|
65
|
-
pillWithDismiss: {
|
|
66
|
-
paddingRight: 38,
|
|
67
|
-
},
|
|
68
102
|
dismissButton: {
|
|
69
103
|
position: "absolute",
|
|
70
|
-
top: 2,
|
|
71
|
-
right: 2,
|
|
72
|
-
padding: 4,
|
|
73
104
|
borderRadius: 999,
|
|
74
105
|
zIndex: 1,
|
|
75
106
|
},
|
package/src/control_surface.ts
CHANGED
|
@@ -179,6 +179,13 @@ export function chipSurfaceStyle(
|
|
|
179
179
|
): ViewStyle {
|
|
180
180
|
const selected = opts?.selected ?? false;
|
|
181
181
|
return {
|
|
182
|
+
/**
|
|
183
|
+
* A chip is a CONTROL and takes the band's height, wherever it stands —
|
|
184
|
+
* including inside a `Select`'s chip box. The container is what adapts: the
|
|
185
|
+
* trigger sets `minHeight`, not `height`, and wraps its selection row, so a
|
|
186
|
+
* box of chips grows to seat them at full size rather than shrinking them
|
|
187
|
+
* to fit a single band.
|
|
188
|
+
*/
|
|
182
189
|
height: CONTROL_HEIGHT,
|
|
183
190
|
justifyContent: "center",
|
|
184
191
|
borderRadius: CONTROL_RADIUS,
|
package/src/file_badge.tsx
CHANGED
|
@@ -16,6 +16,19 @@ const MIME_MAP: Record<string, { label: string; color: string }> = {
|
|
|
16
16
|
"application/x-zip-compressed": { label: "ZIP", color: "#a16207" },
|
|
17
17
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": { label: "DOCX", color: "#2563eb" },
|
|
18
18
|
"application/msword": { label: "DOC", color: "#2563eb" },
|
|
19
|
+
// Text formats a document surface routinely holds — an HTML template, a
|
|
20
|
+
// markdown knowledge doc, a plain-text export. Without them each fell to the
|
|
21
|
+
// grey FILE default, which on a list where every row is one of these is a
|
|
22
|
+
// mark that looks the same on every row and therefore carries nothing.
|
|
23
|
+
"text/html": { label: "HTML", color: "#ea580c" },
|
|
24
|
+
"text/markdown": { label: "MD", color: "#0891b2" },
|
|
25
|
+
// An email MESSAGE, which is a kind of document a template produces and a
|
|
26
|
+
// mailbox exports. It is not `text/html`: an email carries recipients, a
|
|
27
|
+
// subject and a body, and badging one as HTML tells the reader it is a web
|
|
28
|
+
// page. The label is the extension, like every other entry here — the badge
|
|
29
|
+
// is 26px wide at 7px type, which fits four characters, not five.
|
|
30
|
+
"message/rfc822": { label: "EML", color: "#0d9488" },
|
|
31
|
+
"text/plain": { label: "TXT", color: "#71717a" },
|
|
19
32
|
"image/png": { label: "PNG", color: "#7c3aed" },
|
|
20
33
|
"image/jpeg": { label: "JPG", color: "#7c3aed" },
|
|
21
34
|
"video/mp4": { label: "MP4", color: VIDEO_COLOR },
|
package/src/group_avatar.tsx
CHANGED
|
@@ -7,6 +7,15 @@ export interface GroupAvatarProps {
|
|
|
7
7
|
name: string;
|
|
8
8
|
/** A rung on the shared avatar scale. Default `md`. */
|
|
9
9
|
size?: AvatarSize;
|
|
10
|
+
/**
|
|
11
|
+
* When true, the mark announces its `name` to assistive tech. Default false,
|
|
12
|
+
* and for the same reason `Avatar` defaults that way: a group mark almost
|
|
13
|
+
* always sits beside the group's name in visible text, and announcing both
|
|
14
|
+
* reads the team out twice. Pass it where the mark stands ALONE — an access
|
|
15
|
+
* column, a dense cell — because there the initials are all a screen reader
|
|
16
|
+
* would otherwise get, and "Su" is not the name of a team.
|
|
17
|
+
*/
|
|
18
|
+
announce?: boolean;
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
/**
|
|
@@ -18,13 +27,21 @@ export interface GroupAvatarProps {
|
|
|
18
27
|
* picker, a search result — read as the same kind of thing. A group left on a
|
|
19
28
|
* single letter beside a person showing two was the tell that the two had
|
|
20
29
|
* drifted apart.
|
|
30
|
+
*
|
|
31
|
+
* It also shares `Avatar`'s ACCESSIBILITY CONTRACT — decorative by default,
|
|
32
|
+
* naming itself under `announce`. Reading a person's mark and a group's mark
|
|
33
|
+
* must not differ in what they say any more than in how they look, and that
|
|
34
|
+
* includes when they say nothing.
|
|
21
35
|
*/
|
|
22
36
|
export function GroupAvatar(props: GroupAvatarProps) {
|
|
23
|
-
const { name, size = "md" } = props;
|
|
37
|
+
const { name, size = "md", announce } = props;
|
|
24
38
|
const px = AVATAR_PX[size];
|
|
25
39
|
|
|
26
40
|
return (
|
|
27
41
|
<View
|
|
42
|
+
accessible={announce}
|
|
43
|
+
accessibilityLabel={announce ? name : undefined}
|
|
44
|
+
aria-hidden={announce ? undefined : true}
|
|
28
45
|
style={[
|
|
29
46
|
styles.container,
|
|
30
47
|
{
|
|
@@ -34,7 +51,7 @@ export function GroupAvatar(props: GroupAvatarProps) {
|
|
|
34
51
|
},
|
|
35
52
|
]}
|
|
36
53
|
>
|
|
37
|
-
<Text size={AVATAR_TEXT[size]} weight="medium">
|
|
54
|
+
<Text size={AVATAR_TEXT[size]} weight="medium" aria-hidden>
|
|
38
55
|
{avatarInitials(name, size)}
|
|
39
56
|
</Text>
|
|
40
57
|
</View>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ColorName } from "./colors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE LABEL PALETTE — the colour a free-text label (a tag, a category, a topic)
|
|
5
|
+
* is drawn in, derived from the label itself.
|
|
6
|
+
*
|
|
7
|
+
* The sibling of [`avatar_tone`](./avatar_tone.ts), and it exists for the same
|
|
8
|
+
* reason at a different scale: a column of labels rendered in one ink is a
|
|
9
|
+
* column you have to READ, while a column of coloured dots is one you can scan.
|
|
10
|
+
* Handing the hue to the string means a reader learns "the teal one is Vận
|
|
11
|
+
* hành" without being told, and learns it once across every screen.
|
|
12
|
+
*
|
|
13
|
+
* **Nothing is stored.** The colour is recomputed on every render on every
|
|
14
|
+
* client, and the only thing making one label one colour is that this function
|
|
15
|
+
* is pure. That property is what lets tags stay a `text[]` — the moment a
|
|
16
|
+
* colour had to be *chosen*, a tag would need a row to live in, and the join
|
|
17
|
+
* table it implies is the over-modelling the folder system already was.
|
|
18
|
+
* Reordering `LABEL_TONES` or editing the hash repaints every label.
|
|
19
|
+
*
|
|
20
|
+
* **`red` and `amber` are excluded, and that is the load-bearing part.** They
|
|
21
|
+
* mean danger and waiting everywhere else in this system. A palette that can
|
|
22
|
+
* paint an arbitrary noun red makes every red on the screen ambiguous, and the
|
|
23
|
+
* label that happens to hash there reads as urgent to a person who has never
|
|
24
|
+
* been told otherwise. `avatar_tone` excludes them for the same reason ("a
|
|
25
|
+
* person is not a warning"); a topic is not one either.
|
|
26
|
+
*
|
|
27
|
+
* **The order is interleaved, not spectral**, for the reason `avatar_tone`
|
|
28
|
+
* documents at length: listed by hue, six consecutive entries are six shades of
|
|
29
|
+
* the same purple, so two labels hashing to adjacent buckets come out looking
|
|
30
|
+
* identical. Adjacent indices here are far apart in hue.
|
|
31
|
+
*
|
|
32
|
+
* Returns a `ColorName` rather than a hex because its consumer is `Badge`,
|
|
33
|
+
* which owns the shade a dot is drawn at (`solid()`, the 500). `avatarTone`
|
|
34
|
+
* returns a hex instead because it grounds white text and therefore has to pick
|
|
35
|
+
* a per-family shade that clears AA — a real difference, not a missed
|
|
36
|
+
* abstraction.
|
|
37
|
+
*/
|
|
38
|
+
const LABEL_TONES: readonly ColorName[] = [
|
|
39
|
+
"blue",
|
|
40
|
+
"orange",
|
|
41
|
+
"violet",
|
|
42
|
+
"emerald",
|
|
43
|
+
"fuchsia",
|
|
44
|
+
"teal",
|
|
45
|
+
"indigo",
|
|
46
|
+
"pink",
|
|
47
|
+
"cyan",
|
|
48
|
+
"purple",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The colour for `label` — stable for a given string, so one tag keeps one
|
|
53
|
+
* colour across every screen and every session.
|
|
54
|
+
*
|
|
55
|
+
* The hash MIXES rather than sums (`h * 31 + c`), so anagrams and reorderings
|
|
56
|
+
* land in different buckets; `|0` keeps it int32 so every engine agrees.
|
|
57
|
+
*
|
|
58
|
+
* Case- and space-insensitive, which matters more here than for a name: the tag
|
|
59
|
+
* set folds `NOXH` and `noxh` to one label, so the two spellings must not come
|
|
60
|
+
* out as two colours in the window before a write normalizes them.
|
|
61
|
+
*/
|
|
62
|
+
export function labelTone(label: string): ColorName {
|
|
63
|
+
const s = label.trim().toLocaleLowerCase();
|
|
64
|
+
let h = 0;
|
|
65
|
+
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
66
|
+
return LABEL_TONES[Math.abs(h) % LABEL_TONES.length];
|
|
67
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -149,7 +149,7 @@ export interface LoticsLocale {
|
|
|
149
149
|
/** `Avatar`: the fallback name (initials + a11y label) shown when no `name`.
|
|
150
150
|
* `MemberChip` resolves the same blank through it, so a person with no name
|
|
151
151
|
* is called one thing on both. */
|
|
152
|
-
avatar: { unknown: string };
|
|
152
|
+
avatar: { unknown: string; more: (count: number) => string };
|
|
153
153
|
/** `MemberProfileCard` + `MemberPeek`: the profile card's row labels, its
|
|
154
154
|
* "no groups" and departed notes, and the trigger's announced name. */
|
|
155
155
|
memberProfile: Required<MemberProfileLabels>;
|
|
@@ -373,7 +373,7 @@ export const en: LoticsLocale = {
|
|
|
373
373
|
download: "Download file",
|
|
374
374
|
passwordProtected: "This file is password-protected and cannot be previewed",
|
|
375
375
|
},
|
|
376
|
-
avatar: { unknown: "Unknown" },
|
|
376
|
+
avatar: { unknown: "Unknown", more: (count) => `${count} more` },
|
|
377
377
|
memberProfile: {
|
|
378
378
|
role: "Role",
|
|
379
379
|
groups: "Groups",
|
|
@@ -560,7 +560,7 @@ export const vi: LoticsLocale = {
|
|
|
560
560
|
passwordProtected: "Tệp có mật khẩu — không xem trước được",
|
|
561
561
|
},
|
|
562
562
|
mediaPlayer: { loadFailed: "Không tải được nội dung" },
|
|
563
|
-
avatar: { unknown: "Không rõ" },
|
|
563
|
+
avatar: { unknown: "Không rõ", more: (count) => `${count} người khác` },
|
|
564
564
|
memberProfile: {
|
|
565
565
|
role: "Vai trò",
|
|
566
566
|
groups: "Nhóm",
|
package/src/select.tsx
CHANGED
|
@@ -33,6 +33,15 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
|
|
|
33
33
|
allowCustom?: boolean;
|
|
34
34
|
/** Label for the create row (default: `Add "<query>"`). */
|
|
35
35
|
customOptionLabel?: (query: string) => string | null;
|
|
36
|
+
/**
|
|
37
|
+
* The search field's placeholder. Defaults to the locale's "Search…".
|
|
38
|
+
*
|
|
39
|
+
* Worth setting whenever `allowCustom` is on: the create row only appears
|
|
40
|
+
* once there is a query, so on an untyped menu the placeholder is the ONLY
|
|
41
|
+
* always-visible place that can say a new value is allowed at all. A tag
|
|
42
|
+
* field reading "Search…" tells a reader the vocabulary is closed.
|
|
43
|
+
*/
|
|
44
|
+
searchPlaceholder?: string;
|
|
36
45
|
style?: StyleProp<ViewStyle>;
|
|
37
46
|
testID?: string;
|
|
38
47
|
disabled?: boolean;
|
|
@@ -72,6 +81,7 @@ export function Select<T extends string, MULTI extends boolean = false, D = unkn
|
|
|
72
81
|
getOptionDescription,
|
|
73
82
|
searchable = false,
|
|
74
83
|
allowCustom = false,
|
|
84
|
+
searchPlaceholder,
|
|
75
85
|
customOptionLabel,
|
|
76
86
|
includeEmptyOption,
|
|
77
87
|
style,
|
|
@@ -140,7 +150,10 @@ export function Select<T extends string, MULTI extends boolean = false, D = unkn
|
|
|
140
150
|
second scroll that also clips the autofocused search field's outset ring. */}
|
|
141
151
|
<PopoverContent disableBodyScroll>
|
|
142
152
|
<OptionList<T, MULTI, D>
|
|
143
|
-
search={{
|
|
153
|
+
search={{
|
|
154
|
+
mode: searchable || allowCustom ? "internal" : "none",
|
|
155
|
+
placeholder: searchPlaceholder,
|
|
156
|
+
}}
|
|
144
157
|
options={options}
|
|
145
158
|
multi={multi}
|
|
146
159
|
value={value}
|