@lotics/ui 47.8.0 → 47.10.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/docs/catalog.md +47 -6
- package/docs/composition.md +5 -0
- package/docs/templates.md +4 -1
- package/package.json +5 -1
- package/src/brand_mark.tsx +11 -1
- package/src/locale.tsx +18 -0
- package/src/pipeline_tabs.tsx +201 -0
- package/src/social_post_preview.tsx +187 -0
- package/src/social_post_rules.ts +192 -0
- package/src/tabs.tsx +91 -86
- package/src/use_roving_tabs.ts +75 -0
package/docs/catalog.md
CHANGED
|
@@ -425,7 +425,8 @@ component — children are the body), `Subsection` (+ `SubsectionHeading`/
|
|
|
425
425
|
`SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
|
|
426
426
|
top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
|
|
427
427
|
space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
|
|
428
|
-
`PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `
|
|
428
|
+
`PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `PipelineTabs` (Tabs plus order —
|
|
429
|
+
the stage strip of a lifecycle desk, outcomes set apart), `SegmentedControl`,
|
|
429
430
|
`Stepper`, `DangerZone` (the destructive section — delete/archive — set apart at the bottom
|
|
430
431
|
of a record/settings surface).
|
|
431
432
|
|
|
@@ -526,7 +527,9 @@ patterns doc indexed in [AGENTS.md](../AGENTS.md)).
|
|
|
526
527
|
upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow` (allocation),
|
|
527
528
|
`Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
|
|
528
529
|
`CalendarView` (the `calendar` module — days are the axis), `GanttView` (RESOURCES are the
|
|
529
|
-
axis: a channel, an owner, a vehicle), `comments_thread
|
|
530
|
+
axis: a channel, an owner, a vehicle), `comments_thread`, `SocialPostPreview` (a post as ONE
|
|
531
|
+
network's feed shows it, with the count against that network's limit and every rule its API
|
|
532
|
+
would refuse — the publish gate reads the same `socialPostIssues` the preview draws).
|
|
530
533
|
|
|
531
534
|
### AI surfaces
|
|
532
535
|
|
|
@@ -606,7 +609,7 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
606
609
|
`SubsectionHeading` instead. Pick between this and `Eyebrow` by what is LABELLED — a block or a
|
|
607
610
|
value; composition.md § Eyebrow vs group lead has the table.
|
|
608
611
|
- **`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
|
|
612
|
+
happened on — `facebook`, `instagram`, `threads`, `zalo`, `linkedin`, `x`, `google-meet`. Solid single-colour silhouettes
|
|
610
613
|
in each brand's own hex, drawn from CC0 geometry. `size` (default 16) and `muted` (draw in the
|
|
611
614
|
text colour). The name list is an ALLOWLIST — `BRAND_NAMES` is the array and `BrandName` derives
|
|
612
615
|
from it, so a channel with no mark will not typecheck and the call site falls back to a generic
|
|
@@ -800,6 +803,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
800
803
|
the product can name); `joined` comes in as an ISO DATE and the card formats it to month + year;
|
|
801
804
|
`groups` distinguishes absent (no row) from `[]` ("None"). `inactive` draws the departed state as
|
|
802
805
|
a `Badge` — a lifecycle state, not another muted line.
|
|
806
|
+
- **`social_post_preview`** — `SocialPostPreview`: a draft post as one network's feed would show
|
|
807
|
+
it — author line with the network's mark, text folded where THAT feed folds it (or the tail
|
|
808
|
+
past the limit in the danger ink, unfolded, so the characters to cut are the ones on screen),
|
|
809
|
+
media as a carousel strip (Instagram, Threads) or a tiled grid with overflow (Facebook, X,
|
|
810
|
+
LinkedIn) — a lone video plays inline, as every feed plays it — a link as the card or the
|
|
811
|
+
inert text it becomes, and a footer with `count / limit`
|
|
812
|
+
plus each refusal in words. `SOCIAL_NETWORK_RULES` is the one table both the drawing and
|
|
813
|
+
`socialPostIssues(network, draft)` read — both in `social_post_rules`, RN-free, so a gate reads
|
|
814
|
+
them without rendering — so a button gated on the issues refuses exactly what
|
|
815
|
+
the preview shows; `socialPostIssueText` renders one in the pack's words. Never writes, never
|
|
816
|
+
publishes. Counts in code points, which is a guide on X (it weights URLs and CJK), exact
|
|
817
|
+
elsewhere.
|
|
803
818
|
- **`member_peek`** — `MemberPeek`: `MemberChip` + `Peek` + the card above, with the trigger's
|
|
804
819
|
announced name taken from the locale. Off picker options and off rows that already press —
|
|
805
820
|
see the person-display section for why.
|
|
@@ -895,9 +910,30 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
895
910
|
can finish their task without ever opening it. → [composition.md](./composition.md)
|
|
896
911
|
§"Canvas & content column". `Accordion` + `AccordionHeader`/`AccordionTitle`/`AccordionMeta`:
|
|
897
912
|
expandable section rows.
|
|
898
|
-
- **`tabs`** — `Tabs`: switch between content sections; WAI-ARIA tablist + roving tabindex
|
|
899
|
-
|
|
900
|
-
|
|
913
|
+
- **`tabs`** — `Tabs`: switch between content sections; WAI-ARIA tablist + roving tabindex.
|
|
914
|
+
Each `TabOption` takes an optional `status` `ColorName` (a small dot before the label, for a
|
|
915
|
+
tab whose area needs work), `count` (the band's size, as a PROP — never formatted into the
|
|
916
|
+
label) and `caption` (a second line under the label, one pre-formatted figure; once any tab in
|
|
917
|
+
a strip has one, every tab reserves the line so the strip keeps one height). Also exports
|
|
918
|
+
`TabButton`, the ONE tab anatomy `PipelineTabs` shares, plus `TAB_PADDING_V` /
|
|
919
|
+
`TAB_LABEL_LINE` so a strip drawing something beside a tab can sit it on the label line.
|
|
920
|
+
- **`pipeline_tabs`** — `PipelineTabs` + `PipelineTab` + `PipelineOutcomes`: `Tabs` plus ORDER
|
|
921
|
+
— the stage strip over a lifecycle desk, a compound. The root (`value`/`onValueChange`,
|
|
922
|
+
`accessibilityLabel`) is the `tablist` and owns the keyboard ring; each `PipelineTab` (`value`,
|
|
923
|
+
`label`, `count?`, `caption?`, `status?`) is `Tabs`' own `TabButton`, so the two strips cannot
|
|
924
|
+
drift; `PipelineOutcomes` wraps the terminal stages past a rule with no chevrons between them.
|
|
925
|
+
Children in PIPELINE ORDER, the flow joined by chevrons sat on the label line; a tab inserted
|
|
926
|
+
anywhere (through a Fragment too) joins the ring where it sits. `label` is the reader's word —
|
|
927
|
+
a field option's label or a locale string; the strip renders no words of its own. `status` is
|
|
928
|
+
an optional dot in the `Badge variant="dot"` vocabulary, with two honest uses: the verdict on
|
|
929
|
+
the outcomes only (the flow bare — being at a stage is not a status), or every stage in its
|
|
930
|
+
option's colour when the rows below badge that same field; never a decoration picked per
|
|
931
|
+
stage. Reach for it when each
|
|
932
|
+
stage changes what the register under it IS — its columns, its act, its figure — the
|
|
933
|
+
lifecycle desk's tab strip (→ [templates.md](./templates.md) §"App-screen shapes"). NOT a
|
|
934
|
+
filter: stages that show the same columns over fewer rows are a `ChipGroup` with counts. NOT a
|
|
935
|
+
report: a funnel's rates are `Funnel`'s, a share bar is `Breakdown`'s. NOT one record's ladder:
|
|
936
|
+
that is `Checklist connected` / `Stepper`. Owns its horizontal scroll, never the page's.
|
|
901
937
|
- **`segmented_control`** — `SegmentedControl`: the exclusive small-set switch. Picking between
|
|
902
938
|
this, `ChipGroup`, `RadioPicker`, `Select` and `Tabs` is decided by OPTION COUNT and by
|
|
903
939
|
whether the choice filters a view or sets a value — not by taste. →
|
|
@@ -1992,6 +2028,11 @@ a child of the app root, which every overlay's body-level box follows.
|
|
|
1992
2028
|
module-level open-overlay counter every overlay primitive reports into; the host's
|
|
1993
2029
|
shortcut registry reads it synchronously to floor page-level shortcuts while any overlay
|
|
1994
2030
|
is open. Lives in the primitives — never call it from a screen.
|
|
2031
|
+
- **`use_roving_tabs`** — `useRovingTabs({ count, selectedIndex, onSelect })` →
|
|
2032
|
+
`{ tabStopIndex, onKeyDown, bindRef }`: the WAI-ARIA tabs keyboard contract (one tab stop, arrow
|
|
2033
|
+
keys select AND move focus, Home/End) shared by `Tabs` and `Pipeline`. Reach for it when
|
|
2034
|
+
building a NEW `tablist`; a `radiogroup` (`SegmentedControl`) selects on Space/Enter as well and
|
|
2035
|
+
is not this hook.
|
|
1995
2036
|
- **`use_section_nav`** — `useSectionNav`: scroll-spy for a LONG record surface with a left
|
|
1996
2037
|
outline rail — keys in page order → `{scrollRef, onScroll, register(key)→onLayout,
|
|
1997
2038
|
jumpTo(key), activeKey}`; rail items are `MenuButton`s (`current={activeKey===key}` — a
|
package/docs/composition.md
CHANGED
|
@@ -1132,6 +1132,11 @@ by what KIND of thing they are.
|
|
|
1132
1132
|
screen) or a `SegmentedControl` (a mode of it); if neither fits because the two panels really
|
|
1133
1133
|
are different content, that tab was **two destinations wearing one name** and the fix is to
|
|
1134
1134
|
promote both into the strip above.
|
|
1135
|
+
**The one strip that may sit under a `Tabs` is a `PipelineTabs`**, and only because it is not a
|
|
1136
|
+
second copy of anything: its stages change the register's columns and act (the lifecycle desk, →
|
|
1137
|
+
[templates.md](./templates.md)), so it is the section's own content, not a lens on it. The test
|
|
1138
|
+
that keeps this honest is the same one that makes a tab a tab — if every stage showed the same
|
|
1139
|
+
columns, it is a `ChipGroup`.
|
|
1135
1140
|
**The label confesses it before the layout does: a tab named "X and Y" is two tabs.** Reach for
|
|
1136
1141
|
the domain's own list of things — the entities its people already name one at a time — and give
|
|
1137
1142
|
each one a tab; a pair invented to keep the strip short buys nothing, because the pair has to be
|
package/docs/templates.md
CHANGED
|
@@ -115,7 +115,10 @@ the filters, never a second copy of them; each states its own size and trouble a
|
|
|
115
115
|
application, a shipment, a repair. *"What is stuck, and what do I move next?"*
|
|
116
116
|
|
|
117
117
|
- **Tabs: the stages**, and here they earn it — each band carries its own act (confirm, dispatch,
|
|
118
|
-
close), its own summary, often its own columns. Roughly five; past that the tail is a lens.
|
|
118
|
+
close), its own summary, often its own columns. Roughly five; past that the tail is a lens. The
|
|
119
|
+
strip is a `PipelineTabs` — `Tabs` plus order: the same tab anatomy drawn as the flow, each stage
|
|
120
|
+
with its dot, count and caption, the won/lost outcomes set apart — rather than a plain `Tabs`,
|
|
121
|
+
because for a pipeline the ORDER is the subject and a wrapping row of names drops it.
|
|
119
122
|
- **Record: a `Drawer`.** The reader came from the list and returns to it, and the drawer's ◀ ▶
|
|
120
123
|
sequences the band they were reading.
|
|
121
124
|
- **Row vs record:** the row carries what the NEXT act needs — the goods, the money, the clock —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "47.
|
|
3
|
+
"version": "47.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./accordion": "./src/accordion.tsx",
|
|
@@ -250,6 +250,8 @@
|
|
|
250
250
|
"./share_or_download": "./src/share_or_download.ts",
|
|
251
251
|
"./shortcut_badge": "./src/shortcut_badge.tsx",
|
|
252
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",
|
|
253
255
|
"./skeleton": "./src/skeleton.tsx",
|
|
254
256
|
"./skip_link": "./src/skip_link.tsx",
|
|
255
257
|
"./slider": "./src/slider.tsx",
|
|
@@ -275,6 +277,7 @@
|
|
|
275
277
|
"./table": "./src/table.tsx",
|
|
276
278
|
"./table_fit": "./src/table_fit.ts",
|
|
277
279
|
"./tabs": "./src/tabs.tsx",
|
|
280
|
+
"./pipeline_tabs": "./src/pipeline_tabs.tsx",
|
|
278
281
|
"./text": "./src/text.tsx",
|
|
279
282
|
"./text_disclosure": "./src/text_disclosure.tsx",
|
|
280
283
|
"./text_ink": "./src/text_ink.ts",
|
|
@@ -306,6 +309,7 @@
|
|
|
306
309
|
"./use_hover": "./src/use_hover.ts",
|
|
307
310
|
"./use_image_rotation": "./src/use_image_rotation.ts",
|
|
308
311
|
"./use_persisted_state": "./src/use_persisted_state.ts",
|
|
312
|
+
"./use_roving_tabs": "./src/use_roving_tabs.ts",
|
|
309
313
|
"./use_paste_files": {
|
|
310
314
|
"react-native": "./src/use_paste_files.ts",
|
|
311
315
|
"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,201 @@
|
|
|
1
|
+
import { Children, Fragment, createContext, isValidElement, useContext, type ReactNode } from "react";
|
|
2
|
+
import { ScrollView, View } from "react-native";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { colors, type ColorName } from "./colors";
|
|
5
|
+
import { SPACE } from "./spacing";
|
|
6
|
+
import { TabButton, TAB_LABEL_LINE, TAB_PADDING_V } from "./tabs";
|
|
7
|
+
import { useRovingTabs, type RovingTabs } from "./use_roving_tabs";
|
|
8
|
+
|
|
9
|
+
interface PipelineCtx {
|
|
10
|
+
value: string;
|
|
11
|
+
onValueChange: (value: string) => void;
|
|
12
|
+
/** Every tab's value in render order — the flow first, then the outcomes.
|
|
13
|
+
* Index into it is a tab's place in the keyboard ring. */
|
|
14
|
+
order: string[];
|
|
15
|
+
/** Whether any tab carries a caption — then every tab reserves the line. */
|
|
16
|
+
captionSlot: boolean;
|
|
17
|
+
roving: RovingTabs;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PipelineContext = createContext<PipelineCtx | null>(null);
|
|
21
|
+
const OutcomesContext = createContext(false);
|
|
22
|
+
|
|
23
|
+
function usePipeline(component: string): PipelineCtx {
|
|
24
|
+
const ctx = useContext(PipelineContext);
|
|
25
|
+
if (!ctx) throw new Error(`${component} must be rendered inside <PipelineTabs>`);
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PipelineTabsProps<T extends string> {
|
|
30
|
+
/** Accessible name of the tablist. */
|
|
31
|
+
accessibilityLabel: string;
|
|
32
|
+
value: T;
|
|
33
|
+
onValueChange: (value: T) => void;
|
|
34
|
+
/** `PipelineTab`s in PIPELINE ORDER — the flow left to right — then one
|
|
35
|
+
* `PipelineOutcomes` holding the stages a record leaves at. The strip never
|
|
36
|
+
* re-ranks: the sequence is the process. */
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
testID?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PipelineTabProps<T extends string = string> {
|
|
42
|
+
value: T;
|
|
43
|
+
/** The stage's name, in the reader's language — a field's option label, or
|
|
44
|
+
* a locale string. The strip renders no words of its own. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** How many records sit at this stage — see `TabOption.count`. */
|
|
47
|
+
count?: number;
|
|
48
|
+
/** The second line — see `TabOption.caption`. Pre-formatted: the strip knows
|
|
49
|
+
* no currency from a count. */
|
|
50
|
+
caption?: string;
|
|
51
|
+
/** A dot before the label, in the `Badge variant="dot"` vocabulary. Optional,
|
|
52
|
+
* and two honest uses: the VERDICT on an outcome only (`emerald` won, `red`
|
|
53
|
+
* lost — the flow stays bare, since being at a stage is not a status), or
|
|
54
|
+
* every stage in the colour its own option carries, when the rows below
|
|
55
|
+
* badge that same field and the strip should agree with them. Never a
|
|
56
|
+
* decoration picked per stage. */
|
|
57
|
+
status?: ColorName;
|
|
58
|
+
testID?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Collected {
|
|
62
|
+
values: string[];
|
|
63
|
+
captionSlot: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every tab under `children`, in render order, through a Fragment or a
|
|
67
|
+
* `PipelineOutcomes` — so a tab inserted anywhere joins the keyboard ring
|
|
68
|
+
* where it sits. */
|
|
69
|
+
function collect(children: ReactNode, into: Collected = { values: [], captionSlot: false }): Collected {
|
|
70
|
+
Children.forEach(children, (child) => {
|
|
71
|
+
if (!isValidElement(child)) return;
|
|
72
|
+
if (child.type === PipelineTab && isValidElement<PipelineTabProps>(child)) {
|
|
73
|
+
into.values.push(child.props.value);
|
|
74
|
+
if (child.props.caption != null) into.captionSlot = true;
|
|
75
|
+
} else if (
|
|
76
|
+
(child.type === PipelineOutcomes || child.type === Fragment) &&
|
|
77
|
+
isValidElement<{ children?: ReactNode }>(child)
|
|
78
|
+
) {
|
|
79
|
+
collect(child.props.children, into);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return into;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `Tabs` plus ORDER — the tab strip over a lifecycle desk, whose tabs are the
|
|
87
|
+
* stages a population of records is spread across, each changing what the
|
|
88
|
+
* register under it IS (its columns, its act, its figure). A compound: the root
|
|
89
|
+
* owns the selection and the keyboard ring, a `PipelineTab` is one tab, and
|
|
90
|
+
* `PipelineOutcomes` groups the stages a record LEAVES at, past a rule, because
|
|
91
|
+
* a record does not walk into them the way it walks from one open stage to the
|
|
92
|
+
* next.
|
|
93
|
+
*
|
|
94
|
+
* Every tab is `Tabs`' own `TabButton` — the same dot, count, caption line,
|
|
95
|
+
* hover wash and underline — so the two strips cannot drift; what this adds is
|
|
96
|
+
* the chevron between stages of the flow, sat on the label line, and the rule
|
|
97
|
+
* before the outcomes. A flat wrapping `Tabs` says nothing about order, and
|
|
98
|
+
* order is what a pipeline is. Owns its horizontal scroll rather than wrapping
|
|
99
|
+
* the flow onto a second row.
|
|
100
|
+
*
|
|
101
|
+
* Not one record's ladder — that is `Checklist connected` or `Stepper`. Not a
|
|
102
|
+
* report — a funnel's rates are `Funnel`'s, a share bar is `Breakdown`'s.
|
|
103
|
+
*/
|
|
104
|
+
export function PipelineTabs<T extends string>(props: PipelineTabsProps<T>) {
|
|
105
|
+
const { accessibilityLabel, value, onValueChange, children, testID } = props;
|
|
106
|
+
const { values: order, captionSlot } = collect(children);
|
|
107
|
+
const roving = useRovingTabs({
|
|
108
|
+
count: order.length,
|
|
109
|
+
selectedIndex: order.indexOf(value),
|
|
110
|
+
onSelect: (index) => {
|
|
111
|
+
const next = order[index];
|
|
112
|
+
// The ring is built from the tabs' own `value` props, which are T.
|
|
113
|
+
if (next != null) onValueChange(next as T);
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} testID={testID}>
|
|
119
|
+
<PipelineContext.Provider
|
|
120
|
+
value={{ value, onValueChange: (v) => onValueChange(v as T), order, captionSlot, roving }}
|
|
121
|
+
>
|
|
122
|
+
<View
|
|
123
|
+
style={{ flexDirection: "row", alignItems: "flex-start", columnGap: 4 }}
|
|
124
|
+
accessibilityRole="tablist"
|
|
125
|
+
accessibilityLabel={accessibilityLabel}
|
|
126
|
+
>
|
|
127
|
+
{children}
|
|
128
|
+
</View>
|
|
129
|
+
</PipelineContext.Provider>
|
|
130
|
+
</ScrollView>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A box the height of the label line, sat where the label line is, so a
|
|
135
|
+
* joiner drawn beside a tab centres on the words and not on a cell whose
|
|
136
|
+
* centre moves when a caption line is reserved. */
|
|
137
|
+
function OnLabelLine({ width, children }: { width: number; children: ReactNode }) {
|
|
138
|
+
return (
|
|
139
|
+
<View
|
|
140
|
+
style={{
|
|
141
|
+
width,
|
|
142
|
+
height: TAB_PADDING_V + TAB_LABEL_LINE,
|
|
143
|
+
paddingTop: TAB_PADDING_V,
|
|
144
|
+
alignItems: "center",
|
|
145
|
+
justifyContent: "center",
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
{children}
|
|
149
|
+
</View>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The stages a record leaves the pipeline at — won, lost, and anything else
|
|
155
|
+
* terminal. Drawn past a rule, with no chevrons between them: they are
|
|
156
|
+
* alternatives, not a sequence. Render it last.
|
|
157
|
+
*/
|
|
158
|
+
export function PipelineOutcomes({ children }: { children: ReactNode }) {
|
|
159
|
+
usePipeline("PipelineOutcomes");
|
|
160
|
+
return (
|
|
161
|
+
<OutcomesContext.Provider value={true}>
|
|
162
|
+
<OnLabelLine width={SPACE.sm * 2 + 1}>
|
|
163
|
+
<View style={{ width: 1, height: 20, backgroundColor: colors.zinc["200"] }} />
|
|
164
|
+
</OnLabelLine>
|
|
165
|
+
{children}
|
|
166
|
+
</OutcomesContext.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function PipelineTab<T extends string>(props: PipelineTabProps<T>) {
|
|
171
|
+
const { value, label, count, caption, status, testID } = props;
|
|
172
|
+
const { value: selectedValue, onValueChange, order, captionSlot, roving } = usePipeline("PipelineTab");
|
|
173
|
+
const inOutcomes = useContext(OutcomesContext);
|
|
174
|
+
const index = order.indexOf(value);
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start" }}>
|
|
178
|
+
{/* The chevron is what says "then": it sits between two stages of the
|
|
179
|
+
FLOW and nowhere else. The first stage has nothing before it, and an
|
|
180
|
+
outcome is not reached by walking. */}
|
|
181
|
+
{index > 0 && !inOutcomes ? (
|
|
182
|
+
<OnLabelLine width={20}>
|
|
183
|
+
<Icon name="chevron-right" size={14} color={colors.zinc["400"]} />
|
|
184
|
+
</OnLabelLine>
|
|
185
|
+
) : null}
|
|
186
|
+
<TabButton
|
|
187
|
+
ref={roving.bindRef(index)}
|
|
188
|
+
label={label}
|
|
189
|
+
count={count}
|
|
190
|
+
status={status}
|
|
191
|
+
caption={caption}
|
|
192
|
+
captionSlot={captionSlot}
|
|
193
|
+
selected={value === selectedValue}
|
|
194
|
+
isTabStop={index === roving.tabStopIndex}
|
|
195
|
+
onPress={() => onValueChange(value)}
|
|
196
|
+
onKeyDown={(event) => roving.onKeyDown(event, index)}
|
|
197
|
+
testID={testID}
|
|
198
|
+
/>
|
|
199
|
+
</View>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -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/tabs.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
2
1
|
import { View } from "react-native";
|
|
2
|
+
import { useRovingTabs } from "./use_roving_tabs";
|
|
3
3
|
import { PressableHighlight } from "./pressable_highlight";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { colors, solid, type ColorName } from "./colors";
|
|
@@ -25,6 +25,11 @@ export interface TabOption<T extends string> {
|
|
|
25
25
|
* band counts what matched, not what rendered. Omit when every band is
|
|
26
26
|
* always whole. */
|
|
27
27
|
count?: number;
|
|
28
|
+
/** A second line under the label — one figure the band is read by (the money
|
|
29
|
+
* in it, what is overdue), already formatted. Once ANY tab in a strip carries
|
|
30
|
+
* one, every tab reserves the line, so the strip keeps one height and the
|
|
31
|
+
* labels one baseline. */
|
|
32
|
+
caption?: string;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
interface TabsProps<T extends string> {
|
|
@@ -37,43 +42,13 @@ interface TabsProps<T extends string> {
|
|
|
37
42
|
|
|
38
43
|
export function Tabs<T extends string>(props: TabsProps<T>) {
|
|
39
44
|
const { accessibilityLabel, options, selectedTab, onSelectTab } = props;
|
|
40
|
-
const tabRefs = useRef<Array<View | null>>([]);
|
|
41
|
-
|
|
42
|
-
// Roving tabindex + arrow-key navigation per the WAI-ARIA Tabs pattern.
|
|
43
|
-
// Home/End jump to the ends; Left/Right cycle.
|
|
44
|
-
const handleKeyDown = useCallback(
|
|
45
|
-
(event: { key: string; preventDefault?: () => void }, index: number) => {
|
|
46
|
-
const last = options.length - 1;
|
|
47
|
-
let next = index;
|
|
48
|
-
switch (event.key) {
|
|
49
|
-
case "ArrowRight":
|
|
50
|
-
case "ArrowDown":
|
|
51
|
-
next = index === last ? 0 : index + 1;
|
|
52
|
-
break;
|
|
53
|
-
case "ArrowLeft":
|
|
54
|
-
case "ArrowUp":
|
|
55
|
-
next = index === 0 ? last : index - 1;
|
|
56
|
-
break;
|
|
57
|
-
case "Home":
|
|
58
|
-
next = 0;
|
|
59
|
-
break;
|
|
60
|
-
case "End":
|
|
61
|
-
next = last;
|
|
62
|
-
break;
|
|
63
|
-
default:
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
event.preventDefault?.();
|
|
67
|
-
onSelectTab?.(options[next].value);
|
|
68
|
-
tabRefs.current[next]?.focus();
|
|
69
|
-
},
|
|
70
|
-
[onSelectTab, options],
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
// When no tab matches the current selection, the first tab becomes the
|
|
74
|
-
// tab-stop so keyboard users can still enter the group.
|
|
75
45
|
const selectedIndex = options.findIndex((option) => option.value === selectedTab);
|
|
76
|
-
const
|
|
46
|
+
const roving = useRovingTabs({
|
|
47
|
+
count: options.length,
|
|
48
|
+
selectedIndex,
|
|
49
|
+
onSelect: (index) => onSelectTab?.(options[index].value),
|
|
50
|
+
});
|
|
51
|
+
const captionSlot = options.some((option) => option.caption != null);
|
|
77
52
|
|
|
78
53
|
return (
|
|
79
54
|
<View
|
|
@@ -83,62 +58,63 @@ export function Tabs<T extends string>(props: TabsProps<T>) {
|
|
|
83
58
|
>
|
|
84
59
|
{options.map((option, index) => (
|
|
85
60
|
<TabButton
|
|
86
|
-
ref={(
|
|
87
|
-
tabRefs.current[index] = node;
|
|
88
|
-
}}
|
|
61
|
+
ref={roving.bindRef(index)}
|
|
89
62
|
key={option.value}
|
|
90
|
-
|
|
63
|
+
label={option.label}
|
|
64
|
+
count={option.count}
|
|
65
|
+
status={option.status}
|
|
66
|
+
caption={option.caption}
|
|
67
|
+
captionSlot={captionSlot}
|
|
68
|
+
testID={option.testID}
|
|
91
69
|
selected={selectedTab === option.value}
|
|
92
|
-
isTabStop={index === tabStopIndex}
|
|
93
|
-
|
|
94
|
-
onKeyDown={(event) =>
|
|
70
|
+
isTabStop={index === roving.tabStopIndex}
|
|
71
|
+
onPress={() => onSelectTab?.(option.value)}
|
|
72
|
+
onKeyDown={(event) => roving.onKeyDown(event, index)}
|
|
95
73
|
/>
|
|
96
74
|
))}
|
|
97
75
|
</View>
|
|
98
76
|
);
|
|
99
77
|
}
|
|
100
78
|
|
|
101
|
-
|
|
79
|
+
/** The pill's vertical inset. Exported so a strip that draws something BESIDE
|
|
80
|
+
* a tab (a joiner, a rule) can sit it on the label line rather than the
|
|
81
|
+
* cell's centre, which moves the moment a caption line is reserved. */
|
|
82
|
+
export const TAB_PADDING_V = 8;
|
|
83
|
+
/** The label line's height — the body rung's line box. */
|
|
84
|
+
export const TAB_LABEL_LINE = 24;
|
|
85
|
+
const DOT = 6;
|
|
86
|
+
const DOT_GAP = 6;
|
|
87
|
+
|
|
88
|
+
export interface TabButtonProps {
|
|
102
89
|
ref: (node: View | null) => void;
|
|
103
|
-
|
|
90
|
+
label: string;
|
|
91
|
+
count?: number;
|
|
92
|
+
status?: ColorName;
|
|
93
|
+
caption?: string;
|
|
94
|
+
/** Reserve the caption line even when this tab has none — the strip decides,
|
|
95
|
+
* so every tab in it stands the same height. */
|
|
96
|
+
captionSlot?: boolean;
|
|
104
97
|
selected: boolean;
|
|
105
98
|
isTabStop: boolean;
|
|
106
|
-
|
|
99
|
+
onPress: () => void;
|
|
107
100
|
onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
|
|
101
|
+
testID?: string;
|
|
108
102
|
}
|
|
109
103
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
119
|
-
{option.status ? (
|
|
120
|
-
<View style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: solid(option.status) }} />
|
|
121
|
-
) : null}
|
|
122
|
-
<Text
|
|
123
|
-
weight={selected ? "medium" : "regular"}
|
|
124
|
-
color={selected ? "default" : "muted"}
|
|
125
|
-
userSelect="none"
|
|
126
|
-
>
|
|
127
|
-
{option.label}
|
|
128
|
-
</Text>
|
|
129
|
-
{option.count != null ? (
|
|
130
|
-
<Text color="muted" tabular userSelect="none">
|
|
131
|
-
{option.count}
|
|
132
|
-
</Text>
|
|
133
|
-
) : null}
|
|
134
|
-
|
|
135
|
-
</View>
|
|
136
|
-
);
|
|
104
|
+
/**
|
|
105
|
+
* ONE tab — the anatomy `Tabs` and `PipelineTabs` share, so the two strips
|
|
106
|
+
* cannot drift by a pixel: the dot, the label, the count, the optional caption
|
|
107
|
+
* line, the single hover wash on the inner pill, and the primary underline.
|
|
108
|
+
*/
|
|
109
|
+
export function TabButton(props: TabButtonProps) {
|
|
110
|
+
const { ref, label, count, status, caption, captionSlot = false, selected, isTabStop, onPress, onKeyDown, testID } =
|
|
111
|
+
props;
|
|
137
112
|
|
|
138
113
|
return (
|
|
139
114
|
<PressableHighlight
|
|
140
115
|
focusRing
|
|
141
|
-
ref={ref}
|
|
116
|
+
ref={ref}
|
|
117
|
+
style={{
|
|
142
118
|
// WHERE YOU ARE, painted in the INK THAT ACTS — `colors.primary`, which
|
|
143
119
|
// defaults to near-black. A tab rail is the only thing on a screen that
|
|
144
120
|
// answers "which section am I in", and a neutral grey underline would be
|
|
@@ -153,21 +129,17 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
153
129
|
// at page scale, so it reads as one product rather than two brands.
|
|
154
130
|
borderBottomWidth: 3,
|
|
155
131
|
borderBottomColor: selected ? colors.primary : "transparent",
|
|
132
|
+
// Transparent HERE so the one hover wash is the inner pill's.
|
|
156
133
|
backgroundColor: "transparent",
|
|
157
134
|
paddingBottom: 4,
|
|
158
135
|
}}
|
|
159
|
-
onPress={
|
|
136
|
+
onPress={onPress}
|
|
160
137
|
onKeyDown={onKeyDown}
|
|
161
|
-
testID={
|
|
138
|
+
testID={testID}
|
|
162
139
|
accessibilityRole="tab"
|
|
163
|
-
accessibilityLabel={
|
|
140
|
+
accessibilityLabel={label}
|
|
164
141
|
aria-selected={selected}
|
|
165
|
-
// Roving tabindex
|
|
166
|
-
// via arrow keys. When no tab matches the current selection (props.value
|
|
167
|
-
// is stale), the first tab is the fallback so the group stays keyboard-
|
|
168
|
-
// reachable. Drive it with tabIndex, NOT focusable: RN-Web's Pressable
|
|
169
|
-
// ignores `focusable` (it writes its own tabIndex), so a focusable-based
|
|
170
|
-
// roving model silently leaves every tab a tab-stop.
|
|
142
|
+
// Roving tabindex — see `useRovingTabs` for why it is `tabIndex`.
|
|
171
143
|
tabIndex={isTabStop ? 0 : -1}
|
|
172
144
|
>
|
|
173
145
|
{(state) => {
|
|
@@ -176,7 +148,7 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
176
148
|
return (
|
|
177
149
|
<View
|
|
178
150
|
style={{
|
|
179
|
-
paddingVertical:
|
|
151
|
+
paddingVertical: TAB_PADDING_V,
|
|
180
152
|
paddingHorizontal: 16,
|
|
181
153
|
borderRadius: CONTROL_RADIUS,
|
|
182
154
|
backgroundColor: pressed
|
|
@@ -186,7 +158,40 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
|
|
|
186
158
|
: "transparent",
|
|
187
159
|
}}
|
|
188
160
|
>
|
|
189
|
-
{
|
|
161
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: DOT_GAP, height: TAB_LABEL_LINE }}>
|
|
162
|
+
{status ? (
|
|
163
|
+
<View style={{ width: DOT, height: DOT, borderRadius: 999, backgroundColor: solid(status) }} />
|
|
164
|
+
) : null}
|
|
165
|
+
<Text
|
|
166
|
+
weight={selected ? "medium" : "regular"}
|
|
167
|
+
color={selected ? "default" : "muted"}
|
|
168
|
+
userSelect="none"
|
|
169
|
+
numberOfLines={1}
|
|
170
|
+
>
|
|
171
|
+
{label}
|
|
172
|
+
</Text>
|
|
173
|
+
{count != null ? (
|
|
174
|
+
<Text color="muted" tabular userSelect="none">
|
|
175
|
+
{count}
|
|
176
|
+
</Text>
|
|
177
|
+
) : null}
|
|
178
|
+
</View>
|
|
179
|
+
{captionSlot || caption != null ? (
|
|
180
|
+
// Indented past the dot so it starts under the LABEL's first letter
|
|
181
|
+
// — a caption flush with the dot reads as belonging to the dot.
|
|
182
|
+
<Text
|
|
183
|
+
size="xs"
|
|
184
|
+
color="muted"
|
|
185
|
+
tabular
|
|
186
|
+
userSelect="none"
|
|
187
|
+
numberOfLines={1}
|
|
188
|
+
style={{ marginLeft: status ? DOT + DOT_GAP : 0 }}
|
|
189
|
+
>
|
|
190
|
+
{/* A no-break space, not a space: whitespace-only text collapses
|
|
191
|
+
to no height on web, and the reserved line with it. */}
|
|
192
|
+
{caption ?? " "}
|
|
193
|
+
</Text>
|
|
194
|
+
) : null}
|
|
190
195
|
</View>
|
|
191
196
|
);
|
|
192
197
|
}}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { View } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface RovingTabsOptions {
|
|
5
|
+
/** How many tabs the strip holds. */
|
|
6
|
+
count: number;
|
|
7
|
+
/** Index of the selected tab, or -1 when the selection matches none. */
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
/** Arrow keys SELECT as they move — automatic activation, the pattern a tab
|
|
10
|
+
* strip whose panels are cheap to show follows. */
|
|
11
|
+
onSelect: (index: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RovingTabs {
|
|
15
|
+
/** The ONE tab that is a tab stop. When nothing is selected the first tab
|
|
16
|
+
* takes it, so a strip whose `selected` went stale stays keyboard-reachable. */
|
|
17
|
+
tabStopIndex: number;
|
|
18
|
+
/** `onKeyDown` for the tab at `index`: Left/Right and Up/Down cycle (wrapping),
|
|
19
|
+
* Home/End jump to the ends; each selects AND moves focus. */
|
|
20
|
+
onKeyDown: (event: { key: string; preventDefault?: () => void }, index: number) => void;
|
|
21
|
+
/** `ref` for the tab at `index`, so the hook can move focus to it. */
|
|
22
|
+
bindRef: (index: number) => (node: View | null) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The WAI-ARIA tabs keyboard contract — roving tabindex plus arrow-key
|
|
27
|
+
* navigation — shared by every strip that is a `tablist` (`Tabs`, `Pipeline`),
|
|
28
|
+
* so the two cannot drift on which keys do what.
|
|
29
|
+
*
|
|
30
|
+
* The tab stop is driven with `tabIndex`, NOT `focusable`: RN-Web's Pressable
|
|
31
|
+
* ignores `focusable` (it writes its own tabIndex), so a focusable-based roving
|
|
32
|
+
* model silently leaves every tab a tab stop.
|
|
33
|
+
*/
|
|
34
|
+
export function useRovingTabs(options: RovingTabsOptions): RovingTabs {
|
|
35
|
+
const { count, selectedIndex, onSelect } = options;
|
|
36
|
+
const refs = useRef<Array<View | null>>([]);
|
|
37
|
+
|
|
38
|
+
const onKeyDown = useCallback(
|
|
39
|
+
(event: { key: string; preventDefault?: () => void }, index: number) => {
|
|
40
|
+
const last = count - 1;
|
|
41
|
+
let next = index;
|
|
42
|
+
switch (event.key) {
|
|
43
|
+
case "ArrowRight":
|
|
44
|
+
case "ArrowDown":
|
|
45
|
+
next = index === last ? 0 : index + 1;
|
|
46
|
+
break;
|
|
47
|
+
case "ArrowLeft":
|
|
48
|
+
case "ArrowUp":
|
|
49
|
+
next = index === 0 ? last : index - 1;
|
|
50
|
+
break;
|
|
51
|
+
case "Home":
|
|
52
|
+
next = 0;
|
|
53
|
+
break;
|
|
54
|
+
case "End":
|
|
55
|
+
next = last;
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
event.preventDefault?.();
|
|
61
|
+
onSelect(next);
|
|
62
|
+
refs.current[next]?.focus();
|
|
63
|
+
},
|
|
64
|
+
[count, onSelect],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const bindRef = useCallback(
|
|
68
|
+
(index: number) => (node: View | null) => {
|
|
69
|
+
refs.current[index] = node;
|
|
70
|
+
},
|
|
71
|
+
[],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return { tabStopIndex: selectedIndex === -1 ? 0 : selectedIndex, onKeyDown, bindRef };
|
|
75
|
+
}
|