@lotics/ui 47.7.1 → 47.9.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 +6 -0
- package/docs/catalog.md +47 -4
- package/docs/composition.md +20 -0
- package/package.json +5 -1
- package/src/brand_mark.tsx +11 -1
- package/src/locale.tsx +18 -0
- package/src/search_match.ts +18 -0
- package/src/social_post_preview.tsx +187 -0
- package/src/social_post_rules.ts +192 -0
- package/src/switcher.tsx +48 -3
- package/src/table.tsx +91 -25
- package/src/use_option_list.ts +1 -11
- package/src/use_persisted_state.ts +131 -0
package/AGENTS.md
CHANGED
|
@@ -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
|
@@ -526,7 +526,9 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
|
|
|
526
526
|
upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
|
|
527
527
|
`Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
|
|
528
528
|
`CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
|
|
529
|
-
axis: a channel, an owner, a vehicle), `comments_thread
|
|
529
|
+
axis: a channel, an owner, a vehicle), `comments_thread`, `SocialPostPreview` (a post as ONE
|
|
530
|
+
network's feed shows it, with the count against that network's limit and every rule its API
|
|
531
|
+
would refuse — the publish gate reads the same `socialPostIssues` the preview draws).
|
|
530
532
|
|
|
531
533
|
### AI surfaces
|
|
532
534
|
|
|
@@ -606,7 +608,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
606
608
|
`SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
|
|
607
609
|
value; composition.md § Eyebrow vs group lead has the table.
|
|
608
610
|
- **`brand_mark`** — `BrandMark`: an OUTSIDE brand's mark, naming the channel a touchpoint
|
|
609
|
-
happened on — `facebook`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes
|
|
611
|
+
happened on — `facebook`, `instagram`, `threads`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes
|
|
610
612
|
in each brand's own hex, drawn from CC0 geometry. `size` (default 16) and `muted` (draw in the
|
|
611
613
|
text colour). The name list is an ALLOWLIST — `BRAND_NAMES` is the array and `BrandName` derives
|
|
612
614
|
from it, so a channel with no mark will not typecheck and the call site falls back to a generic
|
|
@@ -800,6 +802,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
800
802
|
the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year;
|
|
801
803
|
`groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
|
|
802
804
|
a `Badge` — a lifecycle state, not another muted line.
|
|
805
|
+
- **`social_post_preview`** — `SocialPostPreview`: a draft post as one network's feed would show
|
|
806
|
+
it — author line with the network's mark, text folded where THAT feed folds it (or the tail
|
|
807
|
+
past the limit in the danger ink, unfolded, so the characters to cut are the ones on screen),
|
|
808
|
+
media as a carousel strip (Instagram, Threads) or a tiled grid with overflow (Facebook, X,
|
|
809
|
+
LinkedIn) — a lone video plays inline, as every feed plays it — a link as the card or the
|
|
810
|
+
inert text it becomes, and a footer with `count / limit`
|
|
811
|
+
plus each refusal in words. `SOCIAL_NETWORK_RULES` is the one table both the drawing and
|
|
812
|
+
`socialPostIssues(network, draft)` read — both in `social_post_rules`, RN-free, so a gate reads
|
|
813
|
+
them without rendering — so a button gated on the issues refuses exactly what
|
|
814
|
+
the preview shows; `socialPostIssueText` renders one in the pack's words. Never writes, never
|
|
815
|
+
publishes. Counts in code points, which is a guide on X (it weights URLs and CJK), exact
|
|
816
|
+
elsewhere.
|
|
803
817
|
- **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
|
|
804
818
|
announced name taken from the locale. Off picker options and off rows that already press —
|
|
805
819
|
see the person-display section for why.
|
|
@@ -1049,7 +1063,14 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1049
1063
|
entity/workspace switcher. A square `Avatar` appears on the trigger and every row as soon as
|
|
1050
1064
|
ONE item carries `imageUri`, the rest falling back to initials, so an unpictured entry still
|
|
1051
1065
|
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.
|
|
1066
|
+
the switcher states a scope the reader has no other way to see. It FILTERS past
|
|
1067
|
+
`SWITCHER_SEARCH_FROM` (8) items, and the THRESHOLD is the point: `searchable` is a
|
|
1068
|
+
default rather than an opt-in because the failure it prevents is silent — eight rows
|
|
1069
|
+
is a list, forty is a scroll with no way to jump, and nothing about the second says a
|
|
1070
|
+
prop was missed. Pass `false` on a list that is long but ORDERED (a year of months,
|
|
1071
|
+
where scanning beats typing), `true` on a short one whose labels are hard to tell
|
|
1072
|
+
apart. The query clears on close, so reopening never shows a list narrowed by
|
|
1073
|
+
something typed earlier and no longer on screen.
|
|
1053
1074
|
- **`count`** — `Count`: a 20px count disc that grows into a pill past one digit (`color` highlight|muted|red).
|
|
1054
1075
|
- **`shortcut_badge`** — `ShortcutBadge`: the keycap hint pill — a zinc-50 badge rendering a
|
|
1055
1076
|
shortcut from a raw string or `ShortcutDescriptor` (⌘B on Mac, Ctrl+B elsewhere); null on
|
|
@@ -1743,7 +1764,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1743
1764
|
`TabOption.status` (a `ColorName` dot). Pressing the tab IS the filter, so the count is also the
|
|
1744
1765
|
reason to press it. → [composition.md](./composition.md) §"A partitioning control states its band's size".
|
|
1745
1766
|
- **`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".
|
|
1746
|
-
- **`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".
|
|
1767
|
+
- **`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".
|
|
1747
1768
|
- **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
|
|
1748
1769
|
`SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
|
|
1749
1770
|
`DataGrid` consumers drive. `sortBy` copies rather than mutates, and compares strings with
|
|
@@ -2683,6 +2704,28 @@ component rather than showing it at zero.
|
|
|
2683
2704
|
usually needs both (what is being typed vs. what the results answer). `.cancel()` is for when
|
|
2684
2705
|
a trailing call has been overtaken — a picked option, a cleared box. `Combobox` searches
|
|
2685
2706
|
through it.
|
|
2707
|
+
- **`use_persisted_state`** — `usePersistedState(key, fallback, codec)` + the codecs `asText`,
|
|
2708
|
+
`oneOf`, `asStringSet`, `asSortState`: `useState` that survives a RELOAD, for the things a
|
|
2709
|
+
reader arranged and would otherwise arrange again — which filters are on, how a register is
|
|
2710
|
+
grouped, which bands are folded. Same signature as `useState`, functional updater included, so
|
|
2711
|
+
a screen adopts it by swapping the call. Storage is the browser's, per origin — and every app
|
|
2712
|
+
is served from its own origin, so what a screen stores is private to that app on that machine.
|
|
2713
|
+
**That is also the whole limit: it never reaches another viewer, the same person's other
|
|
2714
|
+
device, or the server.** A convenience belongs here; anything the work depends on belongs in a
|
|
2715
|
+
record. Every access is wrapped, so a private window or a browser that blocks site data costs
|
|
2716
|
+
the arrangement and nothing else.
|
|
2717
|
+
**The `codec.read` is REQUIRED, and that is the point** — what comes back was written by an
|
|
2718
|
+
earlier version of the page, so `JSON.parse` typed as `T` is a lie that surfaces later as a
|
|
2719
|
+
filter set to a value nothing matches. Validate against an ALLOWLIST (`oneOf`, `asSortState`)
|
|
2720
|
+
wherever the value is an option key or a column name, so a retired option restores as "off"
|
|
2721
|
+
rather than as an empty register that reads like missing data. Never persist a value the
|
|
2722
|
+
screen cannot show the reader: a filter whose chip is on screen is legible, a hidden one is a
|
|
2723
|
+
bug report.
|
|
2724
|
+
- **`search_match`** — `normalizeForSearch(s)`: fold case + diacritics for substring matching, so
|
|
2725
|
+
`cafe` finds `Café` and `da giac` finds `Đa Giác`. ONE definition, and every list that filters
|
|
2726
|
+
uses it — two rules is not a style problem: a reader who found a row by typing unaccented text
|
|
2727
|
+
in one list and finds nothing in the next reads the second as missing data. `OptionList`,
|
|
2728
|
+
`Combobox` and `Switcher` all match through it.
|
|
2686
2729
|
- **`use_async_fn`** — `useAsyncFn`: wrap an async function into a manual-trigger mutation —
|
|
2687
2730
|
`[run, {loading, data, error}]`, unmount-safe, the error lands in state AND rethrows; the
|
|
2688
2731
|
pending-state engine for a submit/download/upload action.
|
package/docs/composition.md
CHANGED
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "47.
|
|
3
|
+
"version": "47.9.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",
|
|
@@ -249,6 +250,8 @@
|
|
|
249
250
|
"./share_or_download": "./src/share_or_download.ts",
|
|
250
251
|
"./shortcut_badge": "./src/shortcut_badge.tsx",
|
|
251
252
|
"./size_boundary": "./src/size_boundary.tsx",
|
|
253
|
+
"./social_post_preview": "./src/social_post_preview.tsx",
|
|
254
|
+
"./social_post_rules": "./src/social_post_rules.ts",
|
|
252
255
|
"./skeleton": "./src/skeleton.tsx",
|
|
253
256
|
"./skip_link": "./src/skip_link.tsx",
|
|
254
257
|
"./slider": "./src/slider.tsx",
|
|
@@ -304,6 +307,7 @@
|
|
|
304
307
|
"./use_gated_press": "./src/use_gated_press.ts",
|
|
305
308
|
"./use_hover": "./src/use_hover.ts",
|
|
306
309
|
"./use_image_rotation": "./src/use_image_rotation.ts",
|
|
310
|
+
"./use_persisted_state": "./src/use_persisted_state.ts",
|
|
307
311
|
"./use_paste_files": {
|
|
308
312
|
"react-native": "./src/use_paste_files.ts",
|
|
309
313
|
"default": "./src/use_paste_files.web.ts"
|
package/src/brand_mark.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import { Svg, Path } from "react-native-svg";
|
|
|
18
18
|
* plausible and slightly wrong, which is the one defect a reader spots instantly
|
|
19
19
|
* and a test suite never does.
|
|
20
20
|
*/
|
|
21
|
-
export const BRAND_NAMES = ["facebook", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
|
|
21
|
+
export const BRAND_NAMES = ["facebook", "instagram", "threads", "meta", "tiktok", "google-ads", "zalo", "linkedin", "x", "google-meet"] as const;
|
|
22
22
|
|
|
23
23
|
export type BrandName = (typeof BRAND_NAMES)[number];
|
|
24
24
|
|
|
@@ -73,6 +73,16 @@ const MARKS: Record<BrandName, Mark> = {
|
|
|
73
73
|
color: "#0866FF",
|
|
74
74
|
path: "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z",
|
|
75
75
|
},
|
|
76
|
+
/** The two Meta surfaces a post goes to under a PERSON's name rather than a
|
|
77
|
+
* Page's. Threads' mark is black, like X's — the same `muted` escape applies. */
|
|
78
|
+
instagram: {
|
|
79
|
+
color: "#FF0069",
|
|
80
|
+
path: "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077",
|
|
81
|
+
},
|
|
82
|
+
threads: {
|
|
83
|
+
color: "#000000",
|
|
84
|
+
path: "M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z",
|
|
85
|
+
},
|
|
76
86
|
zalo: {
|
|
77
87
|
color: "#0068FF",
|
|
78
88
|
path: "M12.49 10.2722v-.4496h1.3467v6.3218h-.7704a.576.576 0 01-.5763-.5729l-.0006.0005a3.273 3.273 0 01-1.9372.6321c-1.8138 0-3.2844-1.4697-3.2844-3.2823 0-1.8125 1.4706-3.2822 3.2844-3.2822a3.273 3.273 0 011.9372.6321l.0006.0005zM6.9188 7.7896v.205c0 .3823-.051.6944-.2995 1.0605l-.03.0343c-.0542.0615-.1815.206-.2421.2843L2.024 14.8h4.8948v.7682a.5764.5764 0 01-.5767.5761H0v-.3622c0-.4436.1102-.6414.2495-.8476L4.8582 9.23H.1922V7.7896h6.7266zm8.5513 8.3548a.4805.4805 0 01-.4803-.4798v-7.875h1.4416v8.3548H15.47zM20.6934 9.6C22.52 9.6 24 11.0807 24 12.9044c0 1.8252-1.4801 3.306-3.3066 3.306-1.8264 0-3.3066-1.4808-3.3066-3.306 0-1.8237 1.4802-3.3044 3.3066-3.3044zm-10.1412 5.253c1.0675 0 1.9324-.8645 1.9324-1.9312 0-1.065-.865-1.9295-1.9324-1.9295s-1.9324.8644-1.9324 1.9295c0 1.0667.865 1.9312 1.9324 1.9312zm10.1412-.0033c1.0737 0 1.945-.8707 1.945-1.9453 0-1.073-.8713-1.9436-1.945-1.9436-1.0753 0-1.945.8706-1.945 1.9436 0 1.0746.8697 1.9453 1.945 1.9453z",
|
package/src/locale.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { type DatePickerLabels } from "./date_picker";
|
|
|
3
3
|
import { type CalendarLabels } from "./date_calendar";
|
|
4
4
|
import { type CalendarViewLabels } from "./calendar/types";
|
|
5
5
|
import { type GanttLabels } from "./gantt/types";
|
|
6
|
+
import { type SocialPostPreviewLabels } from "./social_post_rules";
|
|
6
7
|
import { type PaginationLabels } from "./pagination";
|
|
7
8
|
import { type SortHeaderLabels } from "./sort_header";
|
|
8
9
|
import { type ConfidenceLabels } from "./confidence";
|
|
@@ -65,6 +66,9 @@ export interface LoticsLocale {
|
|
|
65
66
|
/** `GanttView` — the zoom switch, the frozen column header, dependency
|
|
66
67
|
* announcements and the empty state. */
|
|
67
68
|
gantt: GanttLabels;
|
|
69
|
+
/** `SocialPostPreview`: the fold's "more", the not-yet-published timestamp,
|
|
70
|
+
* and the refusals each network's API would answer with. */
|
|
71
|
+
socialPostPreview: SocialPostPreviewLabels;
|
|
68
72
|
/** `FilterChip` (and `ColumnFilter`): the generic clear affordance, used when
|
|
69
73
|
* a call site doesn't pass a dimension-specific `clearLabel`. */
|
|
70
74
|
filterChip: { clear: string };
|
|
@@ -348,6 +352,13 @@ export const en: LoticsLocale = {
|
|
|
348
352
|
day: "Day", week: "Week", month: "Month", task: "Task",
|
|
349
353
|
empty: "Nothing scheduled",
|
|
350
354
|
},
|
|
355
|
+
socialPostPreview: {
|
|
356
|
+
more: "more", justNow: "Just now",
|
|
357
|
+
overLimit: (over) => `${over} over the limit`,
|
|
358
|
+
needsMedia: "Needs a photo or video", tooManyImages: (max) => `Up to ${max} images`,
|
|
359
|
+
videoAlone: "A video goes on its own", gifAlone: "A GIF goes on its own",
|
|
360
|
+
linkOrMedia: "A link or media, not both",
|
|
361
|
+
},
|
|
351
362
|
filterChip: { clear: "Clear" },
|
|
352
363
|
filterBand: { filters: "Filters", clearAll: "Clear all", done: "Done" },
|
|
353
364
|
floatingActionBar: { clear: "Clear" },
|
|
@@ -568,6 +579,13 @@ export const vi: LoticsLocale = {
|
|
|
568
579
|
day: "Ngày", week: "Tuần", month: "Tháng", task: "Công việc",
|
|
569
580
|
empty: "Chưa có việc nào",
|
|
570
581
|
},
|
|
582
|
+
socialPostPreview: {
|
|
583
|
+
more: "xem thêm", justNow: "Vừa xong",
|
|
584
|
+
overLimit: (over) => `Quá ${over} ký tự`,
|
|
585
|
+
needsMedia: "Cần ảnh hoặc video", tooManyImages: (max) => `Tối đa ${max} ảnh`,
|
|
586
|
+
videoAlone: "Video phải đăng riêng", gifAlone: "GIF phải đăng riêng",
|
|
587
|
+
linkOrMedia: "Liên kết hoặc ảnh/video, không cả hai",
|
|
588
|
+
},
|
|
571
589
|
filterChip: { clear: "Xóa" },
|
|
572
590
|
filterBand: { filters: "Bộ lọc", clearAll: "Xoá lọc", done: "Xong" },
|
|
573
591
|
floatingActionBar: { clear: "Bỏ chọn" },
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { Avatar } from "./avatar";
|
|
4
|
+
import { BrandMark, type BrandName } from "./brand_mark";
|
|
5
|
+
import { Card } from "./card";
|
|
6
|
+
import { colors } from "./colors";
|
|
7
|
+
import { type DisplayFile } from "./file_thumbnail";
|
|
8
|
+
import { FileThumbnailGrid } from "./file_thumbnail_grid";
|
|
9
|
+
import { MediaPlayer } from "./media_player";
|
|
10
|
+
import { isVideoMimeType } from "./mime";
|
|
11
|
+
import { useLoticsLocale } from "./locale";
|
|
12
|
+
import {
|
|
13
|
+
SOCIAL_NETWORK_RULES,
|
|
14
|
+
countSocialChars,
|
|
15
|
+
socialPostIssueText,
|
|
16
|
+
socialPostIssues,
|
|
17
|
+
type SocialNetwork,
|
|
18
|
+
type SocialNetworkRules,
|
|
19
|
+
type SocialPostDraft,
|
|
20
|
+
type SocialPostLink,
|
|
21
|
+
} from "./social_post_rules";
|
|
22
|
+
import { Text } from "./text";
|
|
23
|
+
|
|
24
|
+
const NETWORK_MARK: Record<SocialNetwork, BrandName> = {
|
|
25
|
+
facebook: "facebook",
|
|
26
|
+
instagram: "instagram",
|
|
27
|
+
threads: "threads",
|
|
28
|
+
x: "x",
|
|
29
|
+
linkedin: "linkedin",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface SocialPostPreviewProps extends SocialPostDraft {
|
|
33
|
+
network: SocialNetwork;
|
|
34
|
+
/** Who the post goes out as — the name and handle the feed prints. */
|
|
35
|
+
author: { name: string; handle?: string; avatarUrl?: string };
|
|
36
|
+
/** The timestamp line. Defaults to the locale's "just now", which is what a
|
|
37
|
+
* post that has not gone out yet would read. */
|
|
38
|
+
when?: string;
|
|
39
|
+
onFilePress?: (file: DisplayFile) => void;
|
|
40
|
+
testID?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A post as one network's feed would show it — the author line, the text
|
|
45
|
+
* folded where that feed folds it, the media in the shape that feed lays it out,
|
|
46
|
+
* the link as the card or the plain text it becomes — with the count against
|
|
47
|
+
* the limit and every rule the API would refuse, in words, under it.
|
|
48
|
+
*
|
|
49
|
+
* It is a PREVIEW, so it never writes and never publishes: the app owns the
|
|
50
|
+
* draft and the button. What it owns is the knowledge of what each network
|
|
51
|
+
* does with the same three inputs, which is exactly the thing a person cannot
|
|
52
|
+
* see from an editor and finds out from a refused request otherwise. That
|
|
53
|
+
* knowledge is `social_post_rules`, which the publish gate reads too, so the
|
|
54
|
+
* button refuses exactly what the preview shows.
|
|
55
|
+
*/
|
|
56
|
+
export function SocialPostPreview(props: SocialPostPreviewProps) {
|
|
57
|
+
const { network, author, text, media = [], link, when, onFilePress, testID } = props;
|
|
58
|
+
const labels = useLoticsLocale().socialPostPreview;
|
|
59
|
+
const rules = SOCIAL_NETWORK_RULES[network];
|
|
60
|
+
const issues = socialPostIssues(network, { text, media, link });
|
|
61
|
+
const count = countSocialChars(text);
|
|
62
|
+
const over = count > rules.maxChars;
|
|
63
|
+
// A carousel network shows one frame at a time, so a strip that scrolls says
|
|
64
|
+
// "swipe" honestly; a feed that tiles shows the tiles and hides the rest
|
|
65
|
+
// behind a count, which is what the grid's overflow draws.
|
|
66
|
+
const carousel = rules.video === "mixed";
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Card style={styles.card} testID={testID}>
|
|
70
|
+
<View style={styles.header}>
|
|
71
|
+
<Avatar name={author.name} size="md" source={author.avatarUrl ? { uri: author.avatarUrl } : undefined} />
|
|
72
|
+
<View style={styles.identity}>
|
|
73
|
+
<Text size="sm" weight="semibold" numberOfLines={1}>{author.name}</Text>
|
|
74
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
75
|
+
{[author.handle, when ?? labels.justNow].filter(Boolean).join(" · ")}
|
|
76
|
+
</Text>
|
|
77
|
+
</View>
|
|
78
|
+
<BrandMark name={NETWORK_MARK[network]} size={18} />
|
|
79
|
+
</View>
|
|
80
|
+
|
|
81
|
+
<View style={styles.body}>
|
|
82
|
+
{text.length > 0 ? <PostText text={text} rules={rules} more={labels.more} /> : null}
|
|
83
|
+
|
|
84
|
+
{media.length === 1 && isVideoMimeType(media[0].mimeType) ? (
|
|
85
|
+
// A lone video PLAYS in every feed — a tile with a play glyph is what a
|
|
86
|
+
// file list shows, not what the post will look like.
|
|
87
|
+
<View style={styles.video}>
|
|
88
|
+
<MediaPlayer src={media[0].url} kind="video" accessibilityLabel={media[0].filename} />
|
|
89
|
+
</View>
|
|
90
|
+
) : media.length > 0 ? (
|
|
91
|
+
<FileThumbnailGrid
|
|
92
|
+
files={[...media]}
|
|
93
|
+
columns={carousel ? undefined : Math.min(media.length, 2)}
|
|
94
|
+
itemSize={carousel ? 144 : undefined}
|
|
95
|
+
singleRow={carousel}
|
|
96
|
+
maxVisible={carousel ? undefined : 4}
|
|
97
|
+
onFilePress={onFilePress}
|
|
98
|
+
disablePress={onFilePress === undefined}
|
|
99
|
+
/>
|
|
100
|
+
) : null}
|
|
101
|
+
|
|
102
|
+
{link && rules.link === "card" && (media.length === 0 || rules.linkBesideMedia) ? (
|
|
103
|
+
<LinkCard link={link} />
|
|
104
|
+
) : null}
|
|
105
|
+
</View>
|
|
106
|
+
|
|
107
|
+
<View style={styles.footer}>
|
|
108
|
+
<Text size="xs" color={over ? "danger" : "muted"} tabular>
|
|
109
|
+
{count} / {rules.maxChars}
|
|
110
|
+
</Text>
|
|
111
|
+
{issues.map((issue) => (
|
|
112
|
+
<Text key={issue.kind} size="xs" color="danger">
|
|
113
|
+
{socialPostIssueText(issue, labels)}
|
|
114
|
+
</Text>
|
|
115
|
+
))}
|
|
116
|
+
</View>
|
|
117
|
+
</Card>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The text as the feed shows it: folded at the network's fold with the "more"
|
|
123
|
+
* the reader would tap, and — when it is over the limit — the tail the network
|
|
124
|
+
* would refuse drawn in the danger ink, unfolded, because a fold would hide the
|
|
125
|
+
* very characters the person has to cut.
|
|
126
|
+
*/
|
|
127
|
+
function PostText({ text, rules, more }: { text: string; rules: SocialNetworkRules; more: string }) {
|
|
128
|
+
const chars = Array.from(text);
|
|
129
|
+
if (chars.length > rules.maxChars) {
|
|
130
|
+
return (
|
|
131
|
+
<Text size="sm">
|
|
132
|
+
{chars.slice(0, rules.maxChars).join("")}
|
|
133
|
+
<Text size="sm" color="danger">{chars.slice(rules.maxChars).join("")}</Text>
|
|
134
|
+
</Text>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (rules.foldChars !== null && chars.length > rules.foldChars) {
|
|
138
|
+
return (
|
|
139
|
+
<Text size="sm">
|
|
140
|
+
{chars.slice(0, rules.foldChars).join("")}…{" "}
|
|
141
|
+
<Text size="sm" color="muted">{more}</Text>
|
|
142
|
+
</Text>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return <Text size="sm">{text}</Text>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function domainOf(url: string): string {
|
|
149
|
+
try {
|
|
150
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
151
|
+
} catch {
|
|
152
|
+
return url;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function LinkCard({ link }: { link: SocialPostLink }) {
|
|
157
|
+
return (
|
|
158
|
+
<View style={styles.linkCard}>
|
|
159
|
+
<Text size="xs" color="muted" numberOfLines={1}>{domainOf(link.url)}</Text>
|
|
160
|
+
<Text size="sm" weight="semibold" numberOfLines={2}>{link.title ?? link.url}</Text>
|
|
161
|
+
</View>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const styles = StyleSheet.create({
|
|
166
|
+
card: { padding: 0, maxWidth: 520, width: "100%" },
|
|
167
|
+
header: { flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 16, paddingTop: 14 },
|
|
168
|
+
identity: { flex: 1, minWidth: 0 },
|
|
169
|
+
body: { paddingHorizontal: 16, paddingVertical: 12, gap: 12 },
|
|
170
|
+
video: { width: "100%", aspectRatio: 16 / 9, borderRadius: 8, overflow: "hidden", backgroundColor: colors.zinc["900"] },
|
|
171
|
+
linkCard: {
|
|
172
|
+
borderWidth: 1,
|
|
173
|
+
borderColor: colors.zinc["200"],
|
|
174
|
+
borderRadius: 8,
|
|
175
|
+
backgroundColor: colors.zinc["50"],
|
|
176
|
+
paddingHorizontal: 12,
|
|
177
|
+
paddingVertical: 10,
|
|
178
|
+
gap: 2,
|
|
179
|
+
},
|
|
180
|
+
footer: {
|
|
181
|
+
borderTopWidth: 1,
|
|
182
|
+
borderTopColor: colors.zinc["200"],
|
|
183
|
+
paddingHorizontal: 16,
|
|
184
|
+
paddingVertical: 8,
|
|
185
|
+
gap: 2,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { DisplayFile } from "./file_thumbnail";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The networks a post can be previewed FOR — the ones whose feed shape and
|
|
5
|
+
* publishing rules this module knows. A surface with no API (a personal Facebook
|
|
6
|
+
* profile, a group, a Zalo OA) has no entry: what it accepts is decided by a
|
|
7
|
+
* person pasting, not by a request the platform can refuse.
|
|
8
|
+
*
|
|
9
|
+
* RN-free on purpose: the publish gate in an app reads this without rendering
|
|
10
|
+
* anything, and the locale packs type their labels off it.
|
|
11
|
+
*/
|
|
12
|
+
export const SOCIAL_NETWORKS = ["facebook", "instagram", "threads", "x", "linkedin"] as const;
|
|
13
|
+
|
|
14
|
+
export type SocialNetwork = (typeof SOCIAL_NETWORKS)[number];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What one network's feed does with a post, and what its API refuses.
|
|
18
|
+
*
|
|
19
|
+
* Two kinds of fact, deliberately in one place: the FOLD (where the feed hides
|
|
20
|
+
* the rest behind "more") is a display fact, the rest are publishing rules the
|
|
21
|
+
* API enforces after the bytes have travelled. Both are what a person needs to
|
|
22
|
+
* see BEFORE pressing publish, so the preview and the gate read one table.
|
|
23
|
+
*/
|
|
24
|
+
export interface SocialNetworkRules {
|
|
25
|
+
/** The characters the API accepts. Counted in code points — X weights URLs
|
|
26
|
+
* and CJK differently, so the count is a guide there, not the network's own. */
|
|
27
|
+
maxChars: number;
|
|
28
|
+
/** Where the feed folds the text behind "more", or null when it never does. */
|
|
29
|
+
foldChars: number | null;
|
|
30
|
+
/** The most images one post carries, or null when the API sets no count. */
|
|
31
|
+
maxImages: number | null;
|
|
32
|
+
/** Whether a video has to be the only attachment. */
|
|
33
|
+
video: "alone" | "mixed";
|
|
34
|
+
/** Whether a GIF is its own kind that goes alone (X) or just an image. */
|
|
35
|
+
gif: "alone" | "image";
|
|
36
|
+
/** What a link becomes: a card, or plain text the feed does not open. */
|
|
37
|
+
link: "card" | "text";
|
|
38
|
+
/** Whether the API takes a link beside media, or refuses the pair. */
|
|
39
|
+
linkBesideMedia: boolean;
|
|
40
|
+
/** Whether a post with no media is refused. */
|
|
41
|
+
requiresMedia: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SOCIAL_NETWORK_RULES: Record<SocialNetwork, SocialNetworkRules> = {
|
|
45
|
+
facebook: {
|
|
46
|
+
maxChars: 63206,
|
|
47
|
+
foldChars: 477,
|
|
48
|
+
maxImages: null,
|
|
49
|
+
video: "alone",
|
|
50
|
+
gif: "image",
|
|
51
|
+
link: "card",
|
|
52
|
+
linkBesideMedia: false,
|
|
53
|
+
requiresMedia: false,
|
|
54
|
+
},
|
|
55
|
+
instagram: {
|
|
56
|
+
maxChars: 2200,
|
|
57
|
+
foldChars: 125,
|
|
58
|
+
maxImages: 10,
|
|
59
|
+
video: "mixed",
|
|
60
|
+
gif: "image",
|
|
61
|
+
// A URL in a caption is inert text. Drawn as no card, not as a refusal:
|
|
62
|
+
// the API takes it, the reader just cannot tap it.
|
|
63
|
+
link: "text",
|
|
64
|
+
linkBesideMedia: true,
|
|
65
|
+
requiresMedia: true,
|
|
66
|
+
},
|
|
67
|
+
threads: {
|
|
68
|
+
maxChars: 500,
|
|
69
|
+
foldChars: null,
|
|
70
|
+
maxImages: 20,
|
|
71
|
+
video: "mixed",
|
|
72
|
+
gif: "image",
|
|
73
|
+
link: "card",
|
|
74
|
+
linkBesideMedia: false,
|
|
75
|
+
requiresMedia: false,
|
|
76
|
+
},
|
|
77
|
+
x: {
|
|
78
|
+
maxChars: 280,
|
|
79
|
+
foldChars: null,
|
|
80
|
+
maxImages: 4,
|
|
81
|
+
video: "alone",
|
|
82
|
+
gif: "alone",
|
|
83
|
+
link: "card",
|
|
84
|
+
linkBesideMedia: true,
|
|
85
|
+
requiresMedia: false,
|
|
86
|
+
},
|
|
87
|
+
linkedin: {
|
|
88
|
+
maxChars: 3000,
|
|
89
|
+
foldChars: 210,
|
|
90
|
+
maxImages: 20,
|
|
91
|
+
video: "alone",
|
|
92
|
+
gif: "image",
|
|
93
|
+
link: "card",
|
|
94
|
+
linkBesideMedia: false,
|
|
95
|
+
requiresMedia: false,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The network's own name. Proper nouns, so not in the locale packs. */
|
|
100
|
+
export const SOCIAL_NETWORK_LABELS: Record<SocialNetwork, string> = {
|
|
101
|
+
facebook: "Facebook",
|
|
102
|
+
instagram: "Instagram",
|
|
103
|
+
threads: "Threads",
|
|
104
|
+
x: "X",
|
|
105
|
+
linkedin: "LinkedIn",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface SocialPostLink {
|
|
109
|
+
url: string;
|
|
110
|
+
title?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What a post IS before it goes out — the three things every network takes. */
|
|
114
|
+
export interface SocialPostDraft {
|
|
115
|
+
text: string;
|
|
116
|
+
media?: readonly DisplayFile[];
|
|
117
|
+
link?: SocialPostLink | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type SocialPostIssue =
|
|
121
|
+
| { kind: "over_limit"; over: number; max: number }
|
|
122
|
+
| { kind: "needs_media" }
|
|
123
|
+
| { kind: "too_many_images"; max: number }
|
|
124
|
+
| { kind: "video_alone" }
|
|
125
|
+
| { kind: "gif_alone" }
|
|
126
|
+
| { kind: "link_or_media" };
|
|
127
|
+
|
|
128
|
+
/** Code points, so a Vietnamese vowel with its tone is one character. */
|
|
129
|
+
export function countSocialChars(text: string): number {
|
|
130
|
+
return Array.from(text).length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mediaKind(file: DisplayFile): "video" | "gif" | "image" {
|
|
134
|
+
if (file.mimeType.startsWith("video/")) return "video";
|
|
135
|
+
if (file.mimeType === "image/gif") return "gif";
|
|
136
|
+
return "image";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Everything the network would refuse, from the draft alone — so the gate can
|
|
141
|
+
* close before the bytes travel, and say why in the same words the preview does.
|
|
142
|
+
* An empty list is not a promise the post lands (a token can have expired, a
|
|
143
|
+
* container can fail to process); it is the promise the REQUEST is well-formed.
|
|
144
|
+
*/
|
|
145
|
+
export function socialPostIssues(network: SocialNetwork, draft: SocialPostDraft): SocialPostIssue[] {
|
|
146
|
+
const rules = SOCIAL_NETWORK_RULES[network];
|
|
147
|
+
const media = draft.media ?? [];
|
|
148
|
+
const kinds = media.map(mediaKind);
|
|
149
|
+
const videos = kinds.filter((k) => k === "video").length;
|
|
150
|
+
const gifs = kinds.filter((k) => k === "gif").length;
|
|
151
|
+
const images = media.length - videos - (rules.gif === "alone" ? gifs : 0);
|
|
152
|
+
const issues: SocialPostIssue[] = [];
|
|
153
|
+
|
|
154
|
+
const count = countSocialChars(draft.text);
|
|
155
|
+
if (count > rules.maxChars) issues.push({ kind: "over_limit", over: count - rules.maxChars, max: rules.maxChars });
|
|
156
|
+
if (rules.requiresMedia && media.length === 0) issues.push({ kind: "needs_media" });
|
|
157
|
+
if (rules.maxImages !== null && images > rules.maxImages) issues.push({ kind: "too_many_images", max: rules.maxImages });
|
|
158
|
+
if (rules.video === "alone" && videos > 0 && media.length > 1) issues.push({ kind: "video_alone" });
|
|
159
|
+
if (rules.gif === "alone" && gifs > 0 && media.length > 1) issues.push({ kind: "gif_alone" });
|
|
160
|
+
if (draft.link && media.length > 0 && !rules.linkBesideMedia) issues.push({ kind: "link_or_media" });
|
|
161
|
+
return issues;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface SocialPostPreviewLabels {
|
|
165
|
+
more: string;
|
|
166
|
+
justNow: string;
|
|
167
|
+
overLimit: (over: number) => string;
|
|
168
|
+
needsMedia: string;
|
|
169
|
+
tooManyImages: (max: number) => string;
|
|
170
|
+
videoAlone: string;
|
|
171
|
+
gifAlone: string;
|
|
172
|
+
linkOrMedia: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One issue, in the pack's words — the same sentence beside the preview and on
|
|
176
|
+
* the gate that reads the same list. */
|
|
177
|
+
export function socialPostIssueText(issue: SocialPostIssue, labels: SocialPostPreviewLabels): string {
|
|
178
|
+
switch (issue.kind) {
|
|
179
|
+
case "over_limit":
|
|
180
|
+
return labels.overLimit(issue.over);
|
|
181
|
+
case "needs_media":
|
|
182
|
+
return labels.needsMedia;
|
|
183
|
+
case "too_many_images":
|
|
184
|
+
return labels.tooManyImages(issue.max);
|
|
185
|
+
case "video_alone":
|
|
186
|
+
return labels.videoAlone;
|
|
187
|
+
case "gif_alone":
|
|
188
|
+
return labels.gifAlone;
|
|
189
|
+
case "link_or_media":
|
|
190
|
+
return labels.linkOrMedia;
|
|
191
|
+
}
|
|
192
|
+
}
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
<
|
|
391
|
-
{
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
778
|
-
|
|
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,
|
package/src/use_option_list.ts
CHANGED
|
@@ -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
|
+
}
|