@lotics/ui 47.7.0 → 47.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -18,7 +18,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
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; findings, provenance, confidence (`Finding` is filed here because AI produces most of them, NOT because it is AI-only — a rules-derived contradiction is as in scope as an extraction's); **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **an identity mark's GLYPH is derived from the name's distinguishing part** (an organization's first word is its legal form, so untreated every row marks one letter), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), **a list stays fast by keeping IDENTITY rather than memoizing** (key by the record not the index; `memo` is the last resort and does nothing against a freshly-built prop), and where a commit sits. |
21
- | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers) the first-line drift a column shows when its cell is sometimes one line and sometimes two, and the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
21
+ | [docs/reviewing.md](./docs/reviewing.md) | **Reviewing a screen you built — and the answer when one "looks bad"** — the other docs say what good looks like; this one says how to find out whether you achieved it. Run it BEFORE the deploy: a typecheck, a lint, a test and `app check` are all blind to how a screen reads. GATES that outrank every treatment question, then the probes, each naming what to COLLECT and the SIGNATURE in the numbers — including the truncation sweep (a row whose halves both measure fine and whose text is cut) the summary-vs-facet check (a strip that keeps describing the unfiltered set) and the derivable-column test (a status badge its neighbour already answers) the first-line drift a column shows when its cell is sometimes one line and sometimes two, the cross-FRAME check (what the screen destroys when a row arrives — the one defect that exists in no single frame), and the semantic-section check (a danger or empty section drawn as an ordinary one, and the primitive nobody reached for). Plus the extraction snippets, what measurement cannot see, and how to act on a finding. |
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. |
24
24
 
@@ -35,6 +35,12 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
35
35
  nearest `SizeBoundary`; wrap one around any region whose width stops tracking its parent's.
36
36
  `useScreenSize()` answers a question about the DEVICE.
37
37
  → [catalog.md §"Utility hooks & plumbing"](./docs/catalog.md).
38
+ - **A view the reader ARRANGED survives the reload.** Filters, grouping, sort and folded bands go
39
+ through `usePersistedState(key, fallback, codec)` — browser storage, per app origin, so it is a
40
+ per-viewer convenience and never a shared or durable fact. Its `codec.read` is a required
41
+ ALLOWLIST: a stored option key whose option has since been retired must restore as "off", not
42
+ as an empty register.
43
+ → [catalog.md §"Utility hooks & plumbing"](./docs/catalog.md).
38
44
  - **No user-facing string is a literal in `src/` when a locale slice already names it.** Every
39
45
  string a component renders resolves prop → `useLoticsLocale()` slice → the English pack.
40
46
  → [composition.md §"Localization — a rendered string is WIRED"](./docs/composition.md).
package/docs/catalog.md CHANGED
@@ -1049,7 +1049,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1049
1049
  entity/workspace switcher. A square `Avatar` appears on the trigger and every row as soon as
1050
1050
  ONE item carries `imageUri`, the rest falling back to initials, so an unpictured entry still
1051
1051
  lines up. `minItemsToRender` defaults to 2 (a single choice is clutter); lower it to 1 where
1052
- the switcher states a scope the reader has no other way to see.
1052
+ the switcher states a scope the reader has no other way to see. It FILTERS past
1053
+ `SWITCHER_SEARCH_FROM` (8) items, and the THRESHOLD is the point: `searchable` is a
1054
+ default rather than an opt-in because the failure it prevents is silent — eight rows
1055
+ is a list, forty is a scroll with no way to jump, and nothing about the second says a
1056
+ prop was missed. Pass `false` on a list that is long but ORDERED (a year of months,
1057
+ where scanning beats typing), `true` on a short one whose labels are hard to tell
1058
+ apart. The query clears on close, so reopening never shows a list narrowed by
1059
+ something typed earlier and no longer on screen.
1053
1060
  - **`count`** — `Count`: a 20px count disc that grows into a pill past one digit (`color` highlight|muted|red).
1054
1061
  - **`shortcut_badge`** — `ShortcutBadge`: the keycap hint pill — a zinc-50 badge rendering a
1055
1062
  shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on
@@ -1675,7 +1682,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1675
1682
  **The row OWNS its `title`/`description` pair, so do not hand-roll one beside it.** Both
1676
1683
  lines are `sm`, `leading="tight"`, no gap between them — the stacked-pair law in
1677
1684
  [composition.md](./composition.md), applied by the component rather than restated at each
1678
- call site. The row is 56.
1685
+ call site. The row's floor is 64 — 12 a side around a tight pair and an `md` leading mark —
1686
+ and it grows for a `description` node taller than a line.
1679
1687
  A `description` passed as a NODE keeps its own leading — the component only sets it on the
1680
1688
  string branch, because forcing a line box on somebody else's subtree re-spaces content that
1681
1689
  may not be a line of text at all.
@@ -1742,7 +1750,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1742
1750
  `TabOption.status` (a `ColorName` dot). Pressing the tab IS the filter, so the count is also the
1743
1751
  reason to press it. → [composition.md](./composition.md) §"A partitioning control states its band's size".
1744
1752
  - **`table`** rhythm — rows are separated by their own height and the hover wash, never by a hairline; the register's one line is the band capping the columns. 72px rows, overridable per row with `TableRow`'s `minHeight` when a surface genuinely needs density. There is no `spacing` prop: a table of VALUES rather than of objects is a different component (`Matrix`, `DataGrid`). → [composition.md](./composition.md) §"The register's rhythm".
1745
- - **`table` › `TableGroup`** — a titled BAND of rows inside a `Table` (`label`, `count?`, `color?` for a single valence dot). Turns a register that presents records into one that triages them: group by what implies a different ACTION, order the bands by what needs the reader now, and let air rather than a rule separate them. → [composition.md](./composition.md) §"A register that TRIAGES".
1753
+ - **`table` › `TableGroup`** — a titled BAND of rows inside a `Table` (`label`, `count?`, `color?` for a single valence dot, `collapsed?` + `onToggleCollapse?`, `footer?`). **Reach for this rather than `DataGrid` whenever the rows are pressable** — both group, but a `DataGrid` row is a plain view by design (its cells hold live editors), so banding a browse-and-drill register with it silently costs the row press and nothing says so until somebody clicks. **Collapse is CONTROLLED**: `collapsed` + `onToggleCollapse` live with the control that grouped the rows, and omitting `onToggleCollapse` leaves an inert band — one that cannot fold must not wear a chevron. A folded band is only safe while it still says how much it is holding, which is what `count` is for. **`footer` is the band's subtotal**, composed from `TableCell`s so its figures land under the columns they total, and rendered only while the band is open. The band spans the register, so it aligns to the register's left edge — never indented to the first column's text. Turns a register that presents records into one that triages them: group by what implies a different ACTION, order the bands by what needs the reader now, and let air rather than a rule separate them. → [composition.md](./composition.md) §"A register that TRIAGES".
1746
1754
  - **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
1747
1755
  `SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
1748
1756
  `DataGrid` consumers drive. `sortBy` copies rather than mutates, and compares strings with
@@ -2682,6 +2690,28 @@ component rather than showing it at zero.
2682
2690
  usually needs both (what is being typed vs. what the results answer). `.cancel()` is for when
2683
2691
  a trailing call has been overtaken — a picked option, a cleared box. `Combobox` searches
2684
2692
  through it.
2693
+ - **`use_persisted_state`** — `usePersistedState(key, fallback, codec)` + the codecs `asText`,
2694
+ `oneOf`, `asStringSet`, `asSortState`: `useState` that survives a RELOAD, for the things a
2695
+ reader arranged and would otherwise arrange again — which filters are on, how a register is
2696
+ grouped, which bands are folded. Same signature as `useState`, functional updater included, so
2697
+ a screen adopts it by swapping the call. Storage is the browser's, per origin — and every app
2698
+ is served from its own origin, so what a screen stores is private to that app on that machine.
2699
+ **That is also the whole limit: it never reaches another viewer, the same person's other
2700
+ device, or the server.** A convenience belongs here; anything the work depends on belongs in a
2701
+ record. Every access is wrapped, so a private window or a browser that blocks site data costs
2702
+ the arrangement and nothing else.
2703
+ **The `codec.read` is REQUIRED, and that is the point** — what comes back was written by an
2704
+ earlier version of the page, so `JSON.parse` typed as `T` is a lie that surfaces later as a
2705
+ filter set to a value nothing matches. Validate against an ALLOWLIST (`oneOf`, `asSortState`)
2706
+ wherever the value is an option key or a column name, so a retired option restores as "off"
2707
+ rather than as an empty register that reads like missing data. Never persist a value the
2708
+ screen cannot show the reader: a filter whose chip is on screen is legible, a hidden one is a
2709
+ bug report.
2710
+ - **`search_match`** — `normalizeForSearch(s)`: fold case + diacritics for substring matching, so
2711
+ `cafe` finds `Café` and `da giac` finds `Đa Giác`. ONE definition, and every list that filters
2712
+ uses it — two rules is not a style problem: a reader who found a row by typing unaccented text
2713
+ in one list and finds nothing in the next reads the second as missing data. `OptionList`,
2714
+ `Combobox` and `Switcher` all match through it.
2685
2715
  - **`use_async_fn`** — `useAsyncFn`: wrap an async function into a manual-trigger mutation —
2686
2716
  `[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the
2687
2717
  pending-state engine for a submit/download/upload action.
@@ -369,6 +369,14 @@ formatted into it, and that is the whole rule: `"Trên tàu (4)"` is a second co
369
369
  screen already computes, and it is the copy that goes stale — the band re-counts on every filter
370
370
  and the string does not.
371
371
 
372
+ **A grouping control CLEARS — it never offers "no grouping" as a choice.** Flat is the
373
+ register's resting state, not one option among N, so putting *None* / *Không nhóm* in the list
374
+ prices the way back at the same click as a way in and leaves the control looking engaged while it
375
+ is doing nothing. Give the chip a clear affordance (the ✕ every other filter already has) and let
376
+ its summary state the dimension only while one is picked. Same reason a grouping chip sits FIRST
377
+ in the band: it changes the register's shape, where the filters beside it only change its
378
+ contents.
379
+
372
380
  It also decides where the count may live. A register whose bands already state their sizes must
373
381
  not carry a strip of chips above it repeating the same three numbers; that is one fact twice. And
374
382
  a count is a reason to PRESS — a control that states how many rows are behind it and does not
@@ -1567,6 +1575,18 @@ column is a sort wearing chrome: it spends a band per value to repeat the cell b
1567
1575
  that is not there.
1568
1576
  - **At most one band carries a dot.** A valence mark on every band is decoration; on the one band
1569
1577
  whose urgency comes from a CLOCK rather than from something someone wrote down, it is a signal.
1578
+ - **Grouping never costs the row press.** A register whose rows open something keeps `Table` +
1579
+ `TableGroup`; reaching for `DataGrid` to get bands trades the press away, because its rows are
1580
+ plain views by design. The tell is that the screen still looks right — bands, counts and columns
1581
+ all render — and only a click reveals the rows are inert. Grouping is a presentation OF rows,
1582
+ not a different kind of screen, so it must not decide which kind you get.
1583
+ - **The band aligns to the REGISTER, not to a column.** It spans every column, so its heading
1584
+ starts on the same left edge the column headings do. Indented to where the first column's text
1585
+ begins it reads as a value inside that column rather than a divider across all of them, and it
1586
+ drifts the day that column's leading chrome changes size.
1587
+ - **The air between bands is a margin OUTSIDE the heading, and the heading's own padding is
1588
+ symmetric.** Padding the top asymmetrically to make room bakes the gap into the pressable's box,
1589
+ so its hover wash and focus ring hang below the words they belong to.
1570
1590
  - **Air separates bands, a hairline separates rows.** The band boundary is the largest break
1571
1591
  inside the register and the row rules are the smallest — drawing both as lines makes them
1572
1592
  compete and the grouping stops reading. No rule under a band heading either; that orphans the
@@ -1751,6 +1771,22 @@ The hit area is unchanged — it paints outside the box rather than growing it
1751
1771
  where it would if the control were not there. The same applies to any verb riding a value: a
1752
1772
  `CopyButton`, an `InlineButton`, a count chip.
1753
1773
 
1774
+ ## "Who can reach this" is drawn as MARKS, on every surface that asks it
1775
+
1776
+ Access is a set of people, so it renders as `AvatarGroup` — a register cell, a settings row, a
1777
+ detail header. A word in place of the marks (`Shared`, `2 people`) answers a smaller question than
1778
+ the faces do: it says THAT the thing is shared without saying WITH WHOM, which is the half the
1779
+ reader opened the screen for, and it puts one datum in two treatments the moment any other surface
1780
+ draws the marks.
1781
+
1782
+ The one case marks cannot carry is the whole organization: "everyone" is not a set you can show
1783
+ three of, and the three that happen to sort first would say something false about who else is in
1784
+ there. That gets an icon and a word.
1785
+
1786
+ Whether the OWNER is among the marks is the caller's decision, not a flag on the component — a
1787
+ register with its own owner column passes the shares alone, a list without one leads with the
1788
+ owner. Compose it; a boolean would make one component answer two questions.
1789
+
1754
1790
  ## Identity marks — a mark that is the same on every row carries nothing
1755
1791
 
1756
1792
  `Avatar` derives its ground from the NAME (`avatar_tone`), and takes `shape="circle" | "square"`
package/docs/reviewing.md CHANGED
@@ -232,6 +232,13 @@ reads as one cluster. One `gap` on a container makes every child a peer; wrap th
232
232
  space the groups. Worst where both items are small grey glyphs on one line, because nothing but
233
233
  spacing distinguishes them.
234
234
 
235
+ **The inverted case is easiest to measure and easiest to miss: a control sitting directly on a
236
+ list.** An action above a `List` with nothing between them measures 0, against the 1px divider
237
+ INSIDE the list — so the boundary between two different things is smaller than the boundary
238
+ between two rows of one thing, and the button reads as the list's first row. Measure the CTA's
239
+ bottom against the first row's top on any panel built as *action, then list*; it is a per-screen
240
+ defect, so finding it on one panel is a reason to sweep the siblings.
241
+
235
242
  → [composition.md](./composition.md) §"Touch & whitespace".
236
243
 
237
244
  ### 5. State diff
@@ -675,6 +682,27 @@ its whole subtree on each background refresh.
675
682
 
676
683
  → [composition.md](./composition.md) §"A list stays fast by keeping IDENTITY, not by memoizing".
677
684
 
685
+ ### 9d. A SEMANTIC section drawn as an ordinary one — and the primitive nobody reached for
686
+ Probes 1 and 2 group by shape and find each group internally consistent. The defect neither sees
687
+ is a section whose MEANING is special — destructive, warning, empty, generated — rendered in the
688
+ same heading treatment, on the same ground, as the ordinary sections beside it. Every measurement
689
+ passes, and the meaning is carried by the heading's WORDS alone: delete the word "Danger" and
690
+ nothing on screen still says it.
691
+
692
+ **Collect**, per section on the surface: heading `rung / weight / ink`, the section's own
693
+ background and border, and the treatment of the action inside it. **The signature** is a
694
+ semantically-marked section byte-identical to its neighbours — and, downstream of that, its
695
+ action reading as the FAINTEST control on a screen whose ordinary Save is solid (probe 2's
696
+ singleton ground, arrived at from the other end).
697
+
698
+ **The cheaper detection is structural: grep the kit for a component named after the meaning
699
+ before composing one.** A section the kit already ships — `DangerZone`, `EmptyState` — hand-rolled
700
+ out of a divider, a heading and a button loses more than its treatment: it loses the SLOTS the
701
+ primitive requires, which is why hand-rolled destructive sections routinely carry no consequence
702
+ line at all. If a screen's section has a name, that name is the thing to search for. And a
703
+ component that exists and is used NOWHERE is the same finding seen from the kit's side — worth a
704
+ sweep, not a second copy.
705
+
678
706
  ### 10. Microcopy
679
707
  - **No punctuation doing a word's job.** ` · ` is banned outright — it claims a relationship while
680
708
  refusing to name it, and it is the loudest templated-metadata tell there is. If a mark cannot be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.7.0",
3
+ "version": "47.8.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -239,6 +239,7 @@
239
239
  "./screen_router": "./src/screen_router.tsx",
240
240
  "./scroll_to_bottom": "./src/scroll_to_bottom.tsx",
241
241
  "./search_input": "./src/search_input.tsx",
242
+ "./search_match": "./src/search_match.ts",
242
243
  "./section_card": "./src/section_card.tsx",
243
244
  "./section_heading": "./src/section_heading.tsx",
244
245
  "./section_stack": "./src/section_stack.tsx",
@@ -304,6 +305,7 @@
304
305
  "./use_gated_press": "./src/use_gated_press.ts",
305
306
  "./use_hover": "./src/use_hover.ts",
306
307
  "./use_image_rotation": "./src/use_image_rotation.ts",
308
+ "./use_persisted_state": "./src/use_persisted_state.ts",
307
309
  "./use_paste_files": {
308
310
  "react-native": "./src/use_paste_files.ts",
309
311
  "default": "./src/use_paste_files.web.ts"
package/src/list_item.tsx CHANGED
@@ -171,18 +171,17 @@ function PressRow(props: {
171
171
  }
172
172
 
173
173
  const styles = StyleSheet.create({
174
- // 56, not 64. The height was sized around a pair rendered as prosetwo 24px
175
- // boxes plus a 2px gap is 50, and 64 left 14px of slack the row did not ask
176
- // for, which is what made a correctly-sized `md` mark (28px, the documented
177
- // rung for a person in a list) look adrift in it. A tight pair is 40, so 56
178
- // gives it the same 8-a-side the rest of the kit's rows carry. A title-only
179
- // row keeps one 20px line centred in the same box, so the two shapes still
180
- // share a beat.
174
+ // 64 around a tight pair of 40 and an `md` (28px) leading mark12 a side,
175
+ // which is what lets a settings list of identity rows read as a list rather
176
+ // than a table. A title-only row keeps one 20px line centred in the same box,
177
+ // so the two shapes share a beat. `minHeight`, not `height`: a `description`
178
+ // node the caller supplies (a chip row, two stacked facts) is taller than a
179
+ // line of text, and a fixed height clips it.
181
180
  container: {
182
181
  flexDirection: "row",
183
182
  alignItems: "center",
184
183
  gap: 8,
185
- height: 56,
184
+ minHeight: 64,
186
185
  paddingHorizontal: 8,
187
186
  marginHorizontal: -8,
188
187
  borderRadius: 12,
@@ -0,0 +1,18 @@
1
+ /** Fold case + diacritics for matching, so search is forgiving: `cafe` matches
2
+ * `Café`, `dieu` matches `Điều`, `da giac` matches `Đa Giác`. Still
3
+ * substring-based (predictable — no subsequence/typo noise); the typed value
4
+ * keeps its original accents.
5
+ *
6
+ * ONE definition, because every list in the kit that filters must agree on what
7
+ * a query matches. Two of them is not a style problem: a reader who found a row
8
+ * by typing unaccented text in one list and finds nothing in the next reads the
9
+ * second as missing data, and nothing on either screen says the rule differed.
10
+ * `Đ`/`đ` needs the explicit fold — NFD leaves it a single codepoint with no
11
+ * combining mark to strip, so it survives the diacritic pass unchanged. */
12
+ export function normalizeForSearch(s: string): string {
13
+ return s
14
+ .toLowerCase()
15
+ .normalize("NFD")
16
+ .replace(/\p{Diacritic}/gu, "")
17
+ .replaceAll(String.fromCharCode(0x111), "d");
18
+ }
package/src/switcher.tsx CHANGED
@@ -1,15 +1,23 @@
1
- import React, { useCallback, useState } from "react";
1
+ import React, { useCallback, useMemo, useState } from "react";
2
2
  import { StyleSheet } from "react-native";
3
3
  import { Avatar } from "./avatar";
4
4
  import { Icon } from "./icon";
5
5
  import { MenuButton } from "./menu_button";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
7
  import { Popover, PopoverContent, PopoverTrigger } from "./popover";
8
+ import { normalizeForSearch } from "./search_match";
9
+ import { SearchInput } from "./search_input";
10
+ import { useLoticsLocale } from "./locale";
8
11
  import { Stack } from "./stack";
9
12
  import { Text } from "./text";
10
13
  import { colors } from "./colors";
11
14
  import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
12
15
 
16
+ /** Where a popover list stops being scannable and starts being a scroll. Eight
17
+ * rows is about one popover's height at the default row rhythm — past it the
18
+ * reader is dragging rather than reading. */
19
+ export const SWITCHER_SEARCH_FROM = 8;
20
+
13
21
  export interface SwitcherItem {
14
22
  id: string;
15
23
  label: string;
@@ -36,6 +44,16 @@ interface SwitcherProps {
36
44
  align?: "start" | "center" | "end";
37
45
  /** Trigger max width in pixels. Default 180. */
38
46
  maxTriggerWidth?: number;
47
+ /**
48
+ * Filter the list by typing. Defaults to ON past {@link SWITCHER_SEARCH_FROM}
49
+ * items — the threshold rather than a flat `false`, because the failure this
50
+ * prevents is SILENT: a switcher over eight things is a short list, a switcher
51
+ * over forty is a scroll with no way to jump, and nothing about the second
52
+ * announces that a prop was missed. Pass `false` to suppress it on a list that
53
+ * is long but ordered (a year of months), or `true` to force it on a short one
54
+ * whose labels are hard to scan.
55
+ */
56
+ searchable?: boolean;
39
57
  }
40
58
 
41
59
  /**
@@ -59,12 +77,23 @@ export function Switcher(props: SwitcherProps) {
59
77
  side = "bottom",
60
78
  align = "start",
61
79
  maxTriggerWidth = 180,
80
+ searchable,
62
81
  } = props;
63
82
  const [open, setOpen] = useState(false);
83
+ const [q, setQ] = useState("");
84
+ const locale = useLoticsLocale();
85
+
86
+ const showSearch = searchable ?? items.length >= SWITCHER_SEARCH_FROM;
87
+ const shown = useMemo(() => {
88
+ const needle = normalizeForSearch(q.trim());
89
+ if (!showSearch || !needle) return items;
90
+ return items.filter((i) => normalizeForSearch(i.label).includes(needle));
91
+ }, [items, q, showSearch]);
64
92
 
65
93
  const handleSelect = useCallback(
66
94
  (id: string) => {
67
95
  setOpen(false);
96
+ setQ("");
68
97
  if (id === currentId) return;
69
98
  onSelect(id);
70
99
  },
@@ -88,7 +117,9 @@ export function Switcher(props: SwitcherProps) {
88
117
  ) : undefined;
89
118
 
90
119
  return (
91
- <Popover open={open} onOpenChange={setOpen} side={side} align={align}>
120
+ // The query resets on close, so reopening never shows a list narrowed by
121
+ // something typed a minute ago and no longer on screen.
122
+ <Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setQ(""); }} side={side} align={align}>
92
123
  <PopoverTrigger>
93
124
  <PressableHighlight focusRing style={[styles.trigger, { maxWidth: maxTriggerWidth }]}>
94
125
  {mark(current)}
@@ -100,7 +131,20 @@ export function Switcher(props: SwitcherProps) {
100
131
  </PopoverTrigger>
101
132
  <PopoverContent>
102
133
  <Stack style={styles.popoverBody}>
103
- {items.map((item) => (
134
+ {showSearch ? (
135
+ <SearchInput
136
+ value={q}
137
+ onChangeText={setQ}
138
+ placeholder={locale.optionList.searchPlaceholder}
139
+ autoFocus
140
+ />
141
+ ) : null}
142
+ {showSearch && shown.length === 0 ? (
143
+ <Text size="sm" color="muted" style={styles.noResults}>
144
+ {locale.optionList.noResults}
145
+ </Text>
146
+ ) : null}
147
+ {shown.map((item) => (
104
148
  <MenuButton
105
149
  key={item.id}
106
150
  icon={mark(item)}
@@ -131,4 +175,5 @@ const styles = StyleSheet.create({
131
175
  popoverBody: {
132
176
  minWidth: 220,
133
177
  },
178
+ noResults: { paddingHorizontal: 10, paddingVertical: 8 },
134
179
  });
package/src/table.tsx CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  import { Animated, StyleSheet, View, type ViewStyle } from "react-native";
14
14
  import { Text } from "./text";
15
15
  import { colors, solid, type ColorName } from "./colors";
16
+ import { Icon } from "./icon";
17
+ import { FocusRingPressable } from "./focus_ring_pressable";
16
18
  import { PressableRow } from "./pressable_row";
17
19
  import { CONTROL_HEIGHT } from "./control_surface";
18
20
  import { PressDoor } from "./press_door";
@@ -86,6 +88,9 @@ const TableContext = createContext<TableCtx | null>(null);
86
88
  */
87
89
  const ORDINAL_W = 28;
88
90
 
91
+ /** The band's leading slot — sized to the chevron, the widest thing it holds. */
92
+ const GROUP_MARK_W = 16;
93
+
89
94
  /**
90
95
  * The row's left chrome: its number, then whatever the caller put in `leading`.
91
96
  *
@@ -335,6 +340,17 @@ export interface TableGroupProps {
335
340
  * label alone understates (a queue that is overdue). Omit for a neutral band —
336
341
  * most bands are neutral, and a dot on every one is decoration. */
337
342
  color?: ColorName;
343
+ /** Folded away. Controlled: which bands are open is the register's own state,
344
+ * and it belongs beside the control that grouped them. The COUNT is what makes
345
+ * folding safe — a closed band still says how much it is holding. */
346
+ collapsed?: boolean;
347
+ /** Omit for an inert band — a heading with no chevron and no tab stop. A band
348
+ * that cannot fold must not look like it can. */
349
+ onToggleCollapse?: () => void;
350
+ /** A subtotal row under the band's rows, composed from `TableCell`s so its
351
+ * figures land under the columns they total. Rendered only while open: a
352
+ * total under a folded band restates a number the band already carries. */
353
+ footer?: ReactNode;
338
354
  /** The `TableRow`s in this band. */
339
355
  children: ReactNode;
340
356
  }
@@ -363,36 +379,73 @@ export interface TableGroupProps {
363
379
  * "which of all".
364
380
  */
365
381
  export function TableGroup(props: TableGroupProps) {
366
- const { label, count, color, children } = props;
382
+ const { label, count, color, collapsed = false, onToggleCollapse, footer, children } = props;
367
383
  const rows = Children.toArray(children).filter(isValidElement);
368
384
  // A band separates its OWN rows the same way the table does. This rendered
369
385
  // hairlines unconditionally, so an airy register lost its rules between bands
370
386
  // and kept them inside — the grouping made the register look half-converted,
371
387
  // and the rule that survived was the one with least reason to.
372
- return (
373
- <View>
374
- <View style={styles.groupHeading}>
375
- {/* Ô này giữ chỗ kể cả khi băng không màu: bỏ hẳn đi thì nhãn của
376
- băng trung tính bắt đầu sớm hơn nhãn băng có màu đúng một chấm cộng
377
- khoảng cách, hai băng nằm ngay cạnh nhau trong cùng một bảng. */}
378
- <View
379
- style={[styles.groupDot, color ? { backgroundColor: solid(color) } : { opacity: 0 }]}
380
- />
381
- <Text size="sm" weight="semibold">
382
- {label}
383
- </Text>
384
- {count != null ? (
385
- <Text size="sm" color="muted" tabular>
386
- {count}
387
- </Text>
388
+ // THE BAND SPANS THE WHOLE REGISTER, so it aligns to the register's own left
389
+ // edge — the same line the column headings start on — not to any one column's
390
+ // text. A heading indented to the subject column reads as a value inside that
391
+ // column rather than a divider across all of them, and it drifts the moment the
392
+ // first column's chrome changes.
393
+ const heading = (
394
+ <View style={styles.groupHeading}>
395
+ {/* ONE slot for the band's leading mark, whatever it holds — the chevron, the
396
+ valence dot, or nothing. Fixed width so the label starts at the same x on
397
+ every band in a register: a collapsible band beside an inert one, or a
398
+ coloured band beside a neutral one, would otherwise step their titles
399
+ apart by the width of a glyph. (The dot already carried this reasoning for
400
+ colour alone; the chevron made it a second axis of the same problem.) */}
401
+ <View style={styles.groupMark}>
402
+ {onToggleCollapse ? (
403
+ <Icon name={collapsed ? "chevron-right" : "chevron-down"} size={15} color={colors.zinc[400]} />
404
+ ) : color ? (
405
+ <View style={[styles.groupDot, { backgroundColor: solid(color) }]} />
388
406
  ) : null}
389
407
  </View>
390
- <View style={styles.body}>
391
- {/* The row's own key, for the reason given in the wide variant above. */}
392
- {rows.map((row, i) => (
393
- <View key={row.key ?? i}>{row}</View>
394
- ))}
395
- </View>
408
+ <Text size="sm" weight="semibold">
409
+ {label}
410
+ </Text>
411
+ {count != null ? (
412
+ <Text size="sm" color="muted" tabular>
413
+ {count}
414
+ </Text>
415
+ ) : null}
416
+ </View>
417
+ );
418
+
419
+ return (
420
+ <View style={styles.groupBand}>
421
+ {onToggleCollapse ? (
422
+ <FocusRingPressable
423
+ onPress={onToggleCollapse}
424
+ accessibilityRole="button"
425
+ // Raw, not `accessibilityState` — this RNW build drops that silently, and a
426
+ // band that folds without announcing it is a control a screen reader reads
427
+ // as a plain button.
428
+ aria-expanded={!collapsed}
429
+ accessibilityLabel={label}
430
+ style={({ hovered }: { hovered?: boolean }) => [
431
+ styles.groupPress,
432
+ hovered ? styles.groupPressHover : null,
433
+ ]}
434
+ >
435
+ {heading}
436
+ </FocusRingPressable>
437
+ ) : (
438
+ heading
439
+ )}
440
+ {collapsed ? null : (
441
+ <View style={styles.body}>
442
+ {/* The row's own key, for the reason given in the wide variant above. */}
443
+ {rows.map((row, i) => (
444
+ <View key={row.key ?? i}>{row}</View>
445
+ ))}
446
+ {footer}
447
+ </View>
448
+ )}
396
449
  </View>
397
450
  );
398
451
  }
@@ -774,10 +827,23 @@ const styles = StyleSheet.create({
774
827
  flexDirection: "row",
775
828
  alignItems: "center",
776
829
  gap: 8,
777
- paddingTop: 26,
778
- paddingBottom: 10,
830
+ // SYMMETRIC, so the hover wash and the focus ring sit centred on the label.
831
+ // The air that separates one band from the band above is `groupBand`'s margin,
832
+ // OUTSIDE the pressable — baked into this padding instead it made every band
833
+ // look bottom-heavy, because the wash is drawn on the padding box.
834
+ paddingVertical: 7,
779
835
  paddingHorizontal: ROW_GUTTER,
780
836
  },
837
+ /** The separation between groups. Outside the pressable — see `groupHeading`. */
838
+ groupBand: { marginTop: 18 },
839
+ groupPress: { borderRadius: 8 },
840
+ /** ONE slot for the band's leading mark, whatever it holds — the chevron, the
841
+ * valence dot, or nothing. Fixed width so the label starts at the same x on
842
+ * every band in a register: a collapsible band beside an inert one, or a
843
+ * coloured band beside a neutral one, would otherwise step their titles apart
844
+ * by the width of a glyph. */
845
+ groupMark: { width: GROUP_MARK_W, alignItems: "center", justifyContent: "center" },
846
+ groupPressHover: { backgroundColor: colors.zinc[50] },
781
847
  groupDot: {
782
848
  width: 6,
783
849
  height: 6,
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
2
2
  import type { ScrollView } from "react-native";
3
+ import { normalizeForSearch } from "./search_match";
3
4
  import { useListKeyboardNav } from "./use_list_keyboard_nav";
4
5
  import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
5
6
 
@@ -98,17 +99,6 @@ export interface UseOptionList<T extends string, D> {
98
99
 
99
100
  const defaultCustomLabel = (query: string) => `Add "${query}"`;
100
101
 
101
- /** Fold case + diacritics for matching, so option search is forgiving: `cafe`
102
- * matches `Café`, `dieu` matches `Điều`. Still substring-based (predictable —
103
- * no subsequence/typo noise); the typed value keeps its original accents. */
104
- function normalizeForSearch(s: string): string {
105
- return s
106
- .toLowerCase()
107
- .normalize("NFD")
108
- .replace(/\p{Diacritic}/gu, "")
109
- .replaceAll(String.fromCharCode(0x111), "d");
110
- }
111
-
112
102
  /**
113
103
  * The headless core of the kit's option listbox — filtering, the custom/empty
114
104
  * rows, single/multi selection + select-all, and keyboard nav + typeahead — with
@@ -0,0 +1,131 @@
1
+ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
2
+ import type { SortState } from "./sort_header";
3
+
4
+ /** How one value survives a reload. `read` is REQUIRED and not optional: what
5
+ * comes back out of storage was written by some earlier version of this page,
6
+ * in some browser, possibly by hand — `JSON.parse` typed as `T` is a lie, and
7
+ * the lie surfaces later as a filter set to a value nothing matches. Validate
8
+ * the shape and return `undefined` to fall back. `write` is only needed when
9
+ * `T` is not something `JSON.stringify` round-trips (a `Set`, a `Map`, a
10
+ * `Date`). */
11
+ export interface PersistedCodec<T> {
12
+ read: (raw: unknown) => T | undefined;
13
+ write?: (value: T) => unknown;
14
+ }
15
+
16
+ /**
17
+ * `useState` that REMEMBERS — for the things a reader arranged and would have to
18
+ * arrange again: which filters are on, how the register is grouped, which bands
19
+ * are folded. Same signature as `useState` (the functional updater included), so
20
+ * a screen adopts it by swapping the call.
21
+ *
22
+ * Storage is per browser and per origin, and every app is served from its own
23
+ * origin, so what a screen stores is private to that app on that machine. It
24
+ * never reaches another viewer, the same person's other device, or the server —
25
+ * which is exactly why this is for CONVENIENCE and never for anything the work
26
+ * depends on. A shared or durable fact belongs in a record.
27
+ *
28
+ * Every access is wrapped: storage can be absent, full, or throw on the accessor
29
+ * itself (a private window, a browser set to block site data, a screenshot
30
+ * runner). The screen must render correctly with nothing stored, which is what
31
+ * `fallback` is for — so a failure here costs the arrangement and nothing else.
32
+ */
33
+ export function usePersistedState<T>(
34
+ key: string,
35
+ fallback: T,
36
+ codec: PersistedCodec<T>,
37
+ ): [T, Dispatch<SetStateAction<T>>] {
38
+ // The codec is inevitably a fresh object literal on every render — reading it
39
+ // through a ref keeps it out of the write effect's dependencies, so the effect
40
+ // fires on a VALUE change rather than on every render.
41
+ const codecRef = useRef(codec);
42
+ codecRef.current = codec;
43
+
44
+ const [value, setValue] = useState<T>(() => {
45
+ const stored = readStored(key);
46
+ if (stored === undefined) return fallback;
47
+ // `undefined` is the ONLY rejection — never `??`. A codec may legitimately
48
+ // revive `null` (a sort the reader cleared), and `??` would quietly restore
49
+ // the default instead, so clearing would be the one arrangement that never
50
+ // survives a reload.
51
+ const revived = codec.read(stored);
52
+ return revived === undefined ? fallback : revived;
53
+ });
54
+
55
+ useEffect(() => {
56
+ const w = codecRef.current.write;
57
+ writeStored(key, w ? w(value) : value);
58
+ }, [key, value]);
59
+
60
+ // Stable, so a screen can pass it straight to a child without re-rendering it
61
+ // on every keystroke elsewhere.
62
+ const set = useCallback<Dispatch<SetStateAction<T>>>((next) => setValue(next), []);
63
+ return [value, set];
64
+ }
65
+
66
+ function store(): Storage | null {
67
+ try {
68
+ return typeof localStorage === "undefined" ? null : localStorage;
69
+ } catch {
70
+ // Accessing the accessor throws where site data is blocked outright.
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function readStored(key: string): unknown {
76
+ try {
77
+ const raw = store()?.getItem(key);
78
+ return raw === null || raw === undefined ? undefined : JSON.parse(raw);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function writeStored(key: string, value: unknown): void {
85
+ try {
86
+ store()?.setItem(key, JSON.stringify(value));
87
+ } catch {
88
+ // Full, or blocked. The screen already holds the value in state.
89
+ }
90
+ }
91
+
92
+ /** The codec for a plain string that must be one of a KNOWN set — a select
93
+ * option key, a grouping dimension. An allowlist rather than a `typeof` check,
94
+ * because a stored key whose option was deleted filters the register down to
95
+ * nothing and looks like missing data. `""` is always allowed: it is how every
96
+ * filter in the kit spells "off". */
97
+ export function oneOf<T extends string>(allowed: readonly T[]): PersistedCodec<T | ""> {
98
+ return {
99
+ read: (raw) =>
100
+ raw === "" ? "" : typeof raw === "string" && (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined,
101
+ };
102
+ }
103
+
104
+ /** The codec for free text a reader typed. */
105
+ export const asText: PersistedCodec<string> = {
106
+ read: (raw) => (typeof raw === "string" ? raw : undefined),
107
+ };
108
+
109
+ /** The codec for a `Set` of strings — stored as an array, since `JSON` has no
110
+ * set. Used for the folded bands of a grouped register. */
111
+ export const asStringSet: PersistedCodec<Set<string>> = {
112
+ read: (raw) => (Array.isArray(raw) && raw.every((x) => typeof x === "string") ? new Set(raw) : undefined),
113
+ write: (v) => [...v],
114
+ };
115
+
116
+ /** The codec for a `Table`'s single-column sort. Takes the columns that are
117
+ * actually sortable, so a stored key from a column since renamed or removed
118
+ * falls back to the register's own order instead of sorting on a field the
119
+ * screen no longer reads. */
120
+ export function asSortState(keys: readonly string[]): PersistedCodec<SortState | null> {
121
+ return {
122
+ read: (raw) => {
123
+ if (raw === null) return null;
124
+ if (typeof raw !== "object") return undefined;
125
+ const { key, dir } = raw as { key?: unknown; dir?: unknown };
126
+ if (typeof key !== "string" || !keys.includes(key)) return undefined;
127
+ if (dir !== "asc" && dir !== "desc") return undefined;
128
+ return { key, dir };
129
+ },
130
+ };
131
+ }