@krapwoo/ds-viewer 0.14.0 → 0.14.2
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/README.md
CHANGED
|
@@ -427,7 +427,8 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
427
427
|
alphabetically after the ones that are. The starter kit uses `Tokens`, then every
|
|
428
428
|
component under `Components`, then `Patterns` (whole screens built from the kit, such as
|
|
429
429
|
SavedTrips) and `Reference` (the generated manifest). A larger catalog might add `Recipes` or
|
|
430
|
-
`Experiences` (full flows) alongside them. Pages sort A–Z
|
|
430
|
+
`Experiences` (full flows) alongside them. Pages sort A–Z by their displayed title (falling back
|
|
431
|
+
to their id when they have none; an exact tie breaks on id) inside each group, regardless of how
|
|
431
432
|
many groups you have. Avoid splitting `Components` into finer categories such as "Actions" or
|
|
432
433
|
"Status" purely to browse by — that makes readers guess where a component lives; search and A–Z
|
|
433
434
|
already find it. A distinct semantic group (Tokens vs. Components vs. Patterns) is a different
|
|
@@ -444,7 +445,8 @@ path. For anything beyond a single list of tokens, use the token-page layouts be
|
|
|
444
445
|
usually can't render. Custom replacements styled after a platform control (a custom switch or
|
|
445
446
|
spinner) are not OS components.
|
|
446
447
|
- **Readable titles.** The page id is its address (`#ControlHeights`) and comes from the file
|
|
447
|
-
name; set `title: 'Control heights'` for the heading, sidebar and search. Pages
|
|
448
|
+
name; set `title: 'Control heights'` for the heading, sidebar and search. Pages now sort by this
|
|
449
|
+
title, not the id — so "Control heights" sorts under "C", not under whatever the id starts with.
|
|
448
450
|
- **Point token pages at their token file.** A token page has no component, so its Source would be
|
|
449
451
|
the page file; set `source: 'src/design-system/tokens.ts'` (project-relative) instead.
|
|
450
452
|
- **Coverage only counts the component's own options.** `doctor`'s `option-not-covered` checks
|
|
@@ -1,15 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
1
|
+
/** Fixed English locale, case-insensitive, accent-distinguishing — the one comparator every
|
|
2
|
+
* canonical ordering (sidebar, filtered matches, previous/next, hash fallback) shares, so none of
|
|
3
|
+
* them can drift against each other. */
|
|
4
|
+
const TITLE_COLLATOR = new Intl.Collator('en', { sensitivity: 'accent' });
|
|
5
|
+
/** THE canonical ordering of two ids: by displayed title (`labels.get(id) ?? id`) first, then —
|
|
6
|
+
* for an equal title — by id with the same collator, then by exact code-unit id comparison for
|
|
7
|
+
* the rare case where case-insensitive ids also compare equal. */
|
|
8
|
+
function compareCanonical(a, b, labels) {
|
|
9
|
+
const byTitle = TITLE_COLLATOR.compare(labels?.get(a) ?? a, labels?.get(b) ?? b);
|
|
10
|
+
if (byTitle !== 0)
|
|
11
|
+
return byTitle;
|
|
12
|
+
const byId = TITLE_COLLATOR.compare(a, b);
|
|
13
|
+
if (byId !== 0)
|
|
14
|
+
return byId;
|
|
15
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
16
|
+
}
|
|
17
|
+
/** THE canonical within-group ordering of section ids: by each id's displayed title
|
|
18
|
+
* (`SectionDef.title`, via `labels`), falling back to the id itself when a page has no title. */
|
|
19
|
+
export function sortIds(ids, labels) {
|
|
20
|
+
return ids.slice().sort((a, b) => compareCanonical(a, b, labels));
|
|
21
|
+
}
|
|
22
|
+
/** Every page id in sidebar order: groups in declared order, ids sorted within each group (by
|
|
23
|
+
* displayed title, via `labels`), first occurrence wins, and ids without a matching section (when
|
|
24
|
+
* `availableIds` is given) are dropped. */
|
|
25
|
+
export function orderedIds(groups, availableIds, labels) {
|
|
9
26
|
const seen = new Set();
|
|
10
27
|
const order = [];
|
|
11
28
|
for (const group of groups) {
|
|
12
|
-
for (const id of sortIds(group.ids)) {
|
|
29
|
+
for (const id of sortIds(group.ids, labels)) {
|
|
13
30
|
if (seen.has(id))
|
|
14
31
|
continue;
|
|
15
32
|
if (availableIds && !availableIds.has(id))
|
|
@@ -46,7 +63,7 @@ memberIds) {
|
|
|
46
63
|
label: group.label,
|
|
47
64
|
ids: sortIds(q ? group.ids.filter((id) => id.toLowerCase().includes(q)
|
|
48
65
|
|| (labels?.get(id)?.toLowerCase().includes(q) ?? false)
|
|
49
|
-
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false)) : group.ids),
|
|
66
|
+
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false)) : group.ids, labels),
|
|
50
67
|
}))
|
|
51
68
|
.filter((group) => group.ids.length > 0);
|
|
52
69
|
}
|
|
@@ -221,7 +221,7 @@ export function CatalogShell<TId extends string>({
|
|
|
221
221
|
|
|
222
222
|
const sectionsById = useMemo(() => new Map(sections.map((def) => [def.id, def])), [sections]);
|
|
223
223
|
const labels = useMemo(() => new Map(sections.filter((def) => def.title).map((def) => [def.id as string, def.title as string])), [sections]);
|
|
224
|
-
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys())), [groups, sectionsById]);
|
|
224
|
+
const order = useMemo(() => orderedIds(groups, new Set(sectionsById.keys()), labels), [groups, sectionsById, labels]);
|
|
225
225
|
// Spec §5.3: every family page's own member ids/aliases, resolved to their page (and member, for
|
|
226
226
|
// anything other than the whole) — used both for an old/alias/member address (`resolveRoute`)
|
|
227
227
|
// and for the sidebar's member search (`memberSearchEntries` below).
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
-
import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle } from 'react-native';
|
|
1
|
+
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AccessibilityInfo, Platform, Pressable, StyleSheet, Text, View, findNodeHandle, type LayoutChangeEvent } from 'react-native';
|
|
3
3
|
import { CATALOG_COLOR, CATALOG_LAYOUT, CATALOG_RADIUS, CATALOG_SPACE, CATALOG_TEXT } from './tokens';
|
|
4
4
|
import { Blocks, SectionPagerRow, type SectionPager } from './SectionBlock';
|
|
5
5
|
import { PropsTable } from './PropsTable';
|
|
@@ -13,6 +13,7 @@ import { hashForId } from './catalogNavigation';
|
|
|
13
13
|
import {
|
|
14
14
|
alsoUsedBySegments,
|
|
15
15
|
anatomyColumns,
|
|
16
|
+
anatomyLayout,
|
|
16
17
|
anatomyPinFor,
|
|
17
18
|
ariaLevelFor,
|
|
18
19
|
buildPartTree,
|
|
@@ -571,6 +572,26 @@ interface MeasurableNode {
|
|
|
571
572
|
measureLayout?: (relativeToNativeNode: number, onSuccess: (left: number, top: number, width: number, height: number) => void, onFail: () => void) => void;
|
|
572
573
|
}
|
|
573
574
|
|
|
575
|
+
/** A node with `focus` (every mounted web host node's real DOM method) — narrower than RN's own
|
|
576
|
+
* `Text`/`View` ref types, the same reason `MeasurableNode` above exists for `measureLayout`. */
|
|
577
|
+
interface FocusableNode {
|
|
578
|
+
focus?: (options?: { preventScroll?: boolean }) => void;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Anatomy-narrow-layout focus fix: lets a legend row (`AnatomyMemberRow`/`AnatomyOtherRow`)
|
|
582
|
+
* register its own focusable name link and report when it gains focus, without either of them
|
|
583
|
+
* needing to know *why* — `AnatomyBox` is the only thing that reads `legendNodesRef`, after a
|
|
584
|
+
* layout-mode or balanced-column change remounts the row that currently holds focus at a new
|
|
585
|
+
* tree position (switching `anatomyLegend` <-> `anatomyColumns`, or redistributing columns)
|
|
586
|
+
* disconnects it and the browser drops focus to `<body>`. `register`'s `key` is stable across
|
|
587
|
+
* that remount (a member id, or `other:<name>` for an "Other page" row), so the *replacement*
|
|
588
|
+
* node at the same address can be found and refocused. */
|
|
589
|
+
interface LegendFocus {
|
|
590
|
+
register: (key: string, node: FocusableNode | null) => void;
|
|
591
|
+
onFocus: (key: string) => void;
|
|
592
|
+
onBlur: (key: string) => void;
|
|
593
|
+
}
|
|
594
|
+
|
|
574
595
|
/** One Part in its tree position: rendered through `FamilyPart` as always, wrapped in a `Branch`
|
|
575
596
|
* only when it has children (a leaf costs nothing extra), with its children nested inside its own
|
|
576
597
|
* block (spec: "so the lines don't cross other parts") at one more 52px gutter. */
|
|
@@ -841,7 +862,7 @@ export function AnatomyPin({ member, other }: { member?: string; other?: string
|
|
|
841
862
|
* when there's a pin to show — a member with no number (outside every `inWhole` section) gets no
|
|
842
863
|
* gutter either, so its name starts flush at the row's own left edge instead of under blank
|
|
843
864
|
* space. */
|
|
844
|
-
function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline'] }) {
|
|
865
|
+
function AnatomyMemberRow({ memberId, description, byId, outline, legendFocus }: { memberId: string; description: string; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; legendFocus: LegendFocus }) {
|
|
845
866
|
const pin = anatomyPinFor(memberId, byId, outline);
|
|
846
867
|
return (
|
|
847
868
|
<View style={styles.legendRow}>
|
|
@@ -854,7 +875,12 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
|
|
|
854
875
|
</View>
|
|
855
876
|
)}
|
|
856
877
|
<View style={styles.legendBody}>
|
|
857
|
-
<Text
|
|
878
|
+
<Text
|
|
879
|
+
role="link"
|
|
880
|
+
style={styles.legendName}
|
|
881
|
+
ref={(node) => legendFocus.register(memberId, node as unknown as FocusableNode)}
|
|
882
|
+
{...({ href: hashForId(memberId), onFocus: () => legendFocus.onFocus(memberId), onBlur: () => legendFocus.onBlur(memberId) } as Record<string, unknown>)}
|
|
883
|
+
>
|
|
858
884
|
{memberId}
|
|
859
885
|
</Text>
|
|
860
886
|
<Text style={styles.legendDesc}>{description}</Text>
|
|
@@ -869,8 +895,11 @@ function AnatomyMemberRow({ memberId, description, byId, outline }: { memberId:
|
|
|
869
895
|
* "other page" row always shows its ↗, so the slot is never blank. Keeping it also keeps
|
|
870
896
|
* APPROVED.md's "the description ... starts at the name's left edge" — pulling the marker inline
|
|
871
897
|
* instead left each description hanging 26px to the left of its own name. */
|
|
872
|
-
function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string; description: string; resolvesAddress: (name: string) => boolean }) {
|
|
898
|
+
function AnatomyOtherRow({ name, description, resolvesAddress, legendFocus }: { name: string; description: string; resolvesAddress: (name: string) => boolean; legendFocus: LegendFocus }) {
|
|
873
899
|
const resolved = resolvesAddress(name);
|
|
900
|
+
// No member id of its own (it names a component documented elsewhere), so it needs its own
|
|
901
|
+
// namespace to never collide with a real member id sharing the same text.
|
|
902
|
+
const focusKey = `other:${name}`;
|
|
874
903
|
return (
|
|
875
904
|
<View style={styles.legendRow}>
|
|
876
905
|
<View style={styles.legendPinSlot} {...ARIA_HIDDEN} testID="legend-pin-slot">
|
|
@@ -881,7 +910,12 @@ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string;
|
|
|
881
910
|
<View style={styles.legendBody}>
|
|
882
911
|
<View style={styles.legendNameRow}>
|
|
883
912
|
{resolved ? (
|
|
884
|
-
<Text
|
|
913
|
+
<Text
|
|
914
|
+
role="link"
|
|
915
|
+
style={styles.legendName}
|
|
916
|
+
ref={(node) => legendFocus.register(focusKey, node as unknown as FocusableNode)}
|
|
917
|
+
{...({ href: hashForId(name), onFocus: () => legendFocus.onFocus(focusKey), onBlur: () => legendFocus.onBlur(focusKey) } as Record<string, unknown>)}
|
|
918
|
+
>
|
|
885
919
|
{name}
|
|
886
920
|
</Text>
|
|
887
921
|
) : (
|
|
@@ -895,57 +929,142 @@ function AnatomyOtherRow({ name, description, resolvesAddress }: { name: string;
|
|
|
895
929
|
);
|
|
896
930
|
}
|
|
897
931
|
|
|
898
|
-
function AnatomyGroup({ group, byId, outline, resolvesAddress }: { group: CatalogAnatomyGroup; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
|
|
932
|
+
function AnatomyGroup({ group, byId, outline, resolvesAddress, legendFocus }: { group: CatalogAnatomyGroup; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean; legendFocus: LegendFocus }) {
|
|
899
933
|
return (
|
|
900
934
|
<View>
|
|
901
935
|
<Text style={styles.legendGroupLabel}>{group.label}</Text>
|
|
902
936
|
{group.items.map((item, i) =>
|
|
903
937
|
'member' in item ? (
|
|
904
|
-
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} />
|
|
938
|
+
<AnatomyMemberRow key={i} memberId={item.member} description={item.description} byId={byId} outline={outline} legendFocus={legendFocus} />
|
|
905
939
|
) : (
|
|
906
|
-
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} />
|
|
940
|
+
<AnatomyOtherRow key={i} name={item.other} description={item.description} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
|
|
907
941
|
),
|
|
908
942
|
)}
|
|
909
943
|
</View>
|
|
910
944
|
);
|
|
911
945
|
}
|
|
912
946
|
|
|
913
|
-
/** Slice D1 item 5: the anatomy box — always first (right after the page head),
|
|
914
|
-
* "Anatomy" label above the picture (the same label-outside-card treatment the
|
|
915
|
-
* below it use), and a legend beside it
|
|
916
|
-
*
|
|
947
|
+
/** Slice D1 item 5, narrow-layout fix: the anatomy box — always first (right after the page head),
|
|
948
|
+
* a plain-card "Anatomy" label above the picture (the same label-outside-card treatment the
|
|
949
|
+
* specimen grids below it use), and a legend beside it or below it, decided by `anatomyLayout`
|
|
950
|
+
* from the card's own measured width and the picture's own measured intrinsic width (web only —
|
|
951
|
+
* see `onCardLayout`/`onPictureLayout` below), in up to three balanced columns when stacked.
|
|
952
|
+
*
|
|
953
|
+
* The stage (and the picture inside it) renders at the same position in this tree whether the
|
|
954
|
+
* legend sits beside it or below it — only the wrapper's own `flexDirection` and the legend's own
|
|
955
|
+
* shape change around it — so toggling between the two never remounts the picture and discards
|
|
956
|
+
* its own authored state or focus. */
|
|
917
957
|
function AnatomyBox({ anatomy, byId, outline, resolvesAddress }: { anatomy: CatalogAnatomy; byId: ReadonlyMap<string, FamilyMember>; outline: FamilyDef['outline']; resolvesAddress: (name: string) => boolean }) {
|
|
918
958
|
const totalItems = anatomy.groups.reduce((sum, group) => sum + group.items.length, 0);
|
|
919
|
-
const
|
|
959
|
+
const [cardWidth, setCardWidth] = useState(0);
|
|
960
|
+
const [pictureWidth, setPictureWidth] = useState(0);
|
|
961
|
+
// Width-driven layout is a web-only concern (field spec: native keeps its existing item-count
|
|
962
|
+
// decision) — `onLayout` itself is the same RN-compatible API either platform would use, but
|
|
963
|
+
// these handlers simply never update state off web, so `anatomyLayout` always sees an
|
|
964
|
+
// unmeasured (0) width on native and keeps falling back to the original `totalItems > 6` rule.
|
|
965
|
+
// `onCardLayout` sits on the card's own interior wrapper (below), not the bordered card itself,
|
|
966
|
+
// so the measured width is the interior content width `anatomyLayout`'s budget already assumes
|
|
967
|
+
// — the bordered card's own box would overstate it by its own 2px (1px each side) border.
|
|
968
|
+
const onCardLayout = (event: LayoutChangeEvent) => {
|
|
969
|
+
if (Platform.OS !== 'web') return;
|
|
970
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
971
|
+
if (next > 0 && next !== cardWidth) setCardWidth(next);
|
|
972
|
+
};
|
|
973
|
+
const onPictureLayout = (event: LayoutChangeEvent) => {
|
|
974
|
+
if (Platform.OS !== 'web') return;
|
|
975
|
+
const next = Math.round(event.nativeEvent.layout.width);
|
|
976
|
+
if (next > 0 && next !== pictureWidth) setPictureWidth(next);
|
|
977
|
+
};
|
|
978
|
+
const { stacked, legendColumns } = anatomyLayout(totalItems, cardWidth, pictureWidth);
|
|
920
979
|
const contextValue = useMemo(() => ({ byId, outline }), [byId, outline]);
|
|
980
|
+
|
|
981
|
+
// Focus fix: a legend row's own focusable name link, keyed by its stable address (a member id,
|
|
982
|
+
// or `other:<name>`), registered by whichever row currently renders it — see `LegendFocus`'s own
|
|
983
|
+
// comment above. Refs/plain mutable state, not React state: recording "who last had focus"
|
|
984
|
+
// must never itself trigger a render.
|
|
985
|
+
const legendNodesRef = useRef<Map<string, FocusableNode | null>>(new Map());
|
|
986
|
+
const focusedLegendKeyRef = useRef<string | undefined>(undefined);
|
|
987
|
+
const legendFocus: LegendFocus = useMemo(
|
|
988
|
+
() => ({
|
|
989
|
+
register: (key, node) => {
|
|
990
|
+
if (node) legendNodesRef.current.set(key, node);
|
|
991
|
+
else legendNodesRef.current.delete(key);
|
|
992
|
+
},
|
|
993
|
+
onFocus: (key) => {
|
|
994
|
+
focusedLegendKeyRef.current = key;
|
|
995
|
+
},
|
|
996
|
+
onBlur: (key) => {
|
|
997
|
+
if (focusedLegendKeyRef.current === key) focusedLegendKeyRef.current = undefined;
|
|
998
|
+
},
|
|
999
|
+
}),
|
|
1000
|
+
[],
|
|
1001
|
+
);
|
|
1002
|
+
// A layout-mode switch (`anatomyLegend` <-> `anatomyColumns`) or a balanced-column count change
|
|
1003
|
+
// can remount the legend row that currently holds focus at a different tree position — the
|
|
1004
|
+
// browser then drops focus to `<body>` (web's own behavior for a removed focused node). Restore
|
|
1005
|
+
// it to the replacement row at the same address, right after that same render commits, the same
|
|
1006
|
+
// web-only `.focus()` convention `scrollAndFocusMember` already uses below. Guarded on
|
|
1007
|
+
// `document.activeElement === document.body` so another control's focus is never overridden.
|
|
1008
|
+
// Explicit blur clears the recorded key, so clicking page background cannot revive stale focus;
|
|
1009
|
+
// disconnecting an active anchor during reflow does not dispatch that blur event.
|
|
1010
|
+
useLayoutEffect(() => {
|
|
1011
|
+
if (Platform.OS !== 'web') return;
|
|
1012
|
+
const key = focusedLegendKeyRef.current;
|
|
1013
|
+
if (!key) return;
|
|
1014
|
+
if (document.activeElement !== document.body) return;
|
|
1015
|
+
legendNodesRef.current.get(key)?.focus?.({ preventScroll: true });
|
|
1016
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1017
|
+
}, [stacked, legendColumns]);
|
|
1018
|
+
|
|
1019
|
+
const stage = (
|
|
1020
|
+
<View style={styles.anatomyStage}>
|
|
1021
|
+
{/* Measures the picture's own intrinsic width, not the stage: the stage stretches to the
|
|
1022
|
+
card's full width when stacked (a plain View's default cross-axis stretch in a column
|
|
1023
|
+
layout), but this wrapper's own `alignItems: 'center'` parent never stretches it in turn,
|
|
1024
|
+
so the picture's measured width stays the same whichever layout is in effect. Web only:
|
|
1025
|
+
this measuring wrapper would otherwise give an authored picture (e.g. one already using
|
|
1026
|
+
`alignSelf: 'stretch'` or percentage sizing) a new containing block it never had before —
|
|
1027
|
+
native keeps receiving `anatomy.picture` directly, as it always did. */}
|
|
1028
|
+
{Platform.OS === 'web' ? (
|
|
1029
|
+
// `testID` exists only so a renderer test can trigger a real resize on exactly this node
|
|
1030
|
+
// without depending on the author's own picture markup — see `legendPinSlot`'s comment.
|
|
1031
|
+
<View onLayout={onPictureLayout} testID="anatomy-picture-measure">
|
|
1032
|
+
{anatomy.picture}
|
|
1033
|
+
</View>
|
|
1034
|
+
) : (
|
|
1035
|
+
anatomy.picture
|
|
1036
|
+
)}
|
|
1037
|
+
</View>
|
|
1038
|
+
);
|
|
1039
|
+
|
|
921
1040
|
return (
|
|
922
1041
|
<FamilyAnatomyContext.Provider value={contextValue}>
|
|
923
1042
|
<View style={styles.anatomy}>
|
|
924
1043
|
<Text style={styles.anatomyHeadText}>Anatomy</Text>
|
|
925
1044
|
<View style={styles.anatomyCard}>
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1045
|
+
<View style={stacked ? undefined : styles.anatomyBody} onLayout={onCardLayout} testID="anatomy-body">
|
|
1046
|
+
{stage}
|
|
1047
|
+
{stacked ? (
|
|
1048
|
+
// `testID` exists only so a renderer test can assert which layout rendered without
|
|
1049
|
+
// depending on geometry jsdom can't provide (no ResizeObserver there — `onLayout`
|
|
1050
|
+
// never fires), the same reason `legendPinSlot` carries one above.
|
|
1051
|
+
<View style={styles.anatomyColumns} testID="anatomy-columns">
|
|
1052
|
+
{anatomyColumns(anatomy.groups, legendColumns).map((column, i) => (
|
|
1053
|
+
<View key={i} style={styles.anatomyColumn}>
|
|
1054
|
+
{column.map((group, j) => (
|
|
1055
|
+
<AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
|
|
1056
|
+
))}
|
|
1057
|
+
</View>
|
|
1058
|
+
))}
|
|
1059
|
+
</View>
|
|
1060
|
+
) : (
|
|
1061
|
+
<View style={styles.anatomyLegend} testID="anatomy-legend">
|
|
1062
|
+
{anatomy.groups.map((group, i) => (
|
|
1063
|
+
<AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} legendFocus={legendFocus} />
|
|
1064
|
+
))}
|
|
1065
|
+
</View>
|
|
1066
|
+
)}
|
|
947
1067
|
</View>
|
|
948
|
-
)}
|
|
949
1068
|
</View>
|
|
950
1069
|
</View>
|
|
951
1070
|
</FamilyAnatomyContext.Provider>
|
|
@@ -5,22 +5,40 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { NavGroup, SectionDef, UpdateNotice, VersionStatus } from './types.ts';
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** Fixed English locale, case-insensitive, accent-distinguishing — the one comparator every
|
|
9
|
+
* canonical ordering (sidebar, filtered matches, previous/next, hash fallback) shares, so none of
|
|
10
|
+
* them can drift against each other. */
|
|
11
|
+
const TITLE_COLLATOR = new Intl.Collator('en', { sensitivity: 'accent' });
|
|
12
|
+
|
|
13
|
+
/** THE canonical ordering of two ids: by displayed title (`labels.get(id) ?? id`) first, then —
|
|
14
|
+
* for an equal title — by id with the same collator, then by exact code-unit id comparison for
|
|
15
|
+
* the rare case where case-insensitive ids also compare equal. */
|
|
16
|
+
function compareCanonical<TId extends string>(a: TId, b: TId, labels?: ReadonlyMap<string, string>): number {
|
|
17
|
+
const byTitle = TITLE_COLLATOR.compare(labels?.get(a) ?? a, labels?.get(b) ?? b);
|
|
18
|
+
if (byTitle !== 0) return byTitle;
|
|
19
|
+
const byId = TITLE_COLLATOR.compare(a, b);
|
|
20
|
+
if (byId !== 0) return byId;
|
|
21
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** THE canonical within-group ordering of section ids: by each id's displayed title
|
|
25
|
+
* (`SectionDef.title`, via `labels`), falling back to the id itself when a page has no title. */
|
|
26
|
+
export function sortIds<TId extends string>(ids: readonly TId[], labels?: ReadonlyMap<string, string>): TId[] {
|
|
27
|
+
return ids.slice().sort((a, b) => compareCanonical(a, b, labels));
|
|
11
28
|
}
|
|
12
29
|
|
|
13
|
-
/** Every page id in sidebar order: groups in declared order, ids sorted within each group
|
|
14
|
-
* first occurrence wins, and ids without a matching section (when
|
|
15
|
-
* dropped. */
|
|
30
|
+
/** Every page id in sidebar order: groups in declared order, ids sorted within each group (by
|
|
31
|
+
* displayed title, via `labels`), first occurrence wins, and ids without a matching section (when
|
|
32
|
+
* `availableIds` is given) are dropped. */
|
|
16
33
|
export function orderedIds<TId extends string>(
|
|
17
34
|
groups: readonly NavGroup<TId>[],
|
|
18
35
|
availableIds?: ReadonlySet<TId>,
|
|
36
|
+
labels?: ReadonlyMap<string, string>,
|
|
19
37
|
): TId[] {
|
|
20
38
|
const seen = new Set<TId>();
|
|
21
39
|
const order: TId[] = [];
|
|
22
40
|
for (const group of groups) {
|
|
23
|
-
for (const id of sortIds(group.ids)) {
|
|
41
|
+
for (const id of sortIds(group.ids, labels)) {
|
|
24
42
|
if (seen.has(id)) continue;
|
|
25
43
|
if (availableIds && !availableIds.has(id)) continue;
|
|
26
44
|
seen.add(id);
|
|
@@ -65,7 +83,7 @@ export function filterGroups<TId extends string>(
|
|
|
65
83
|
id.toLowerCase().includes(q)
|
|
66
84
|
|| (labels?.get(id)?.toLowerCase().includes(q) ?? false)
|
|
67
85
|
|| (memberIds?.get(id)?.some((memberId) => memberId.toLowerCase().includes(q)) ?? false),
|
|
68
|
-
) : group.ids),
|
|
86
|
+
) : group.ids, labels),
|
|
69
87
|
}))
|
|
70
88
|
.filter((group) => group.ids.length > 0);
|
|
71
89
|
}
|
|
@@ -59,6 +59,46 @@ export function anatomyPinFor(
|
|
|
59
59
|
return { number: entry.number, dashed: member.fidelity === 'documentation-only' };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** The anatomy stage's own padding on each side — equal to `CATALOG_SPACE.xl` (asserted against
|
|
63
|
+
* that token in familyModel.test.ts), not a second, independent constant. A side-by-side legend
|
|
64
|
+
* only fits once the card is wide enough for the picture's own measured width plus this padding
|
|
65
|
+
* on both sides, plus one readable legend column. */
|
|
66
|
+
export const ANATOMY_STAGE_PADDING = 24;
|
|
67
|
+
|
|
68
|
+
/** A legend column — whether the single column beside the picture, or one of the stacked legend's
|
|
69
|
+
* own balanced columns — is never narrower than this: the longest observed member name (~203px)
|
|
70
|
+
* plus its row's own pin gutter and padding (~74px). Below it, the legend drops to fewer columns,
|
|
71
|
+
* or (when there's no room even for one column beside the stage) the whole card stacks instead. */
|
|
72
|
+
export const ANATOMY_LEGEND_MIN_COLUMN_WIDTH = 280;
|
|
73
|
+
|
|
74
|
+
/** How many columns a stacked legend uses at a given measured card width, up to 3 — the same
|
|
75
|
+
* minimum-column-width budget a side-by-side legend's own single column keeps. */
|
|
76
|
+
function anatomyLegendColumns(cardWidth: number): 1 | 2 | 3 {
|
|
77
|
+
const fits = (n: number) => cardWidth >= n * ANATOMY_LEGEND_MIN_COLUMN_WIDTH;
|
|
78
|
+
if (fits(3)) return 3;
|
|
79
|
+
if (fits(2)) return 2;
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Slice fix (anatomy-narrow-layout): whether the anatomy box puts its legend beside the picture
|
|
84
|
+
* or below it, and how many columns a stacked legend uses, at a measured anatomy-card width and
|
|
85
|
+
* picture width. More than six items always stacks (unchanged), but its own legend still narrows
|
|
86
|
+
* to 2 or 1 columns rather than staying cramped at 3. Six or fewer items stay side by side only
|
|
87
|
+
* while the card has room for BOTH the intact picture stage (the picture's own measured width
|
|
88
|
+
* plus `ANATOMY_STAGE_PADDING` on each side) AND one readable legend column
|
|
89
|
+
* (`ANATOMY_LEGEND_MIN_COLUMN_WIDTH`); otherwise they stack too, with the same column budget as
|
|
90
|
+
* the >6 path. `cardWidth`/`pictureWidth` of 0 (nothing measured yet, e.g. native's first frame,
|
|
91
|
+
* or a platform that never measures at all) falls back to the original item-count-only decision,
|
|
92
|
+
* the same one this whole page used before width ever mattered — that decision never oscillates,
|
|
93
|
+
* being a pure function of its own inputs. */
|
|
94
|
+
export function anatomyLayout(totalItems: number, cardWidth: number, pictureWidth: number): { stacked: boolean; legendColumns: 1 | 2 | 3 } {
|
|
95
|
+
if (cardWidth <= 0 || pictureWidth <= 0) return { stacked: totalItems > 6, legendColumns: 3 };
|
|
96
|
+
if (totalItems > 6) return { stacked: true, legendColumns: anatomyLegendColumns(cardWidth) };
|
|
97
|
+
const sideBySideWidth = pictureWidth + 2 * ANATOMY_STAGE_PADDING + ANATOMY_LEGEND_MIN_COLUMN_WIDTH;
|
|
98
|
+
if (cardWidth >= sideBySideWidth) return { stacked: false, legendColumns: 1 };
|
|
99
|
+
return { stacked: true, legendColumns: anatomyLegendColumns(cardWidth) };
|
|
100
|
+
}
|
|
101
|
+
|
|
62
102
|
/** Distributes a legend's groups into up to `maxColumns` columns so the columns end as close to
|
|
63
103
|
* level as possible: each group, in authored order, goes into the column that is currently
|
|
64
104
|
* shortest (the leftmost on a tie). A group's height is its items plus one row for its label. A
|