@krapwoo/ds-viewer 0.14.1 → 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.
@@ -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 role="link" style={styles.legendName} {...({ href: hashForId(memberId) } as Record<string, unknown>)}>
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 role="link" style={styles.legendName} {...({ href: hashForId(name) } as Record<string, unknown>)}>
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), a plain-card
914
- * "Anatomy" label above the picture (the same label-outside-card treatment the specimen grids
915
- * below it use), and a legend beside it (six items or fewer) or below it in up to three balanced
916
- * columns (more than six). */
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 stacked = totalItems > 6;
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
- {stacked ? (
927
- <View>
928
- <View style={styles.anatomyStage}>{anatomy.picture}</View>
929
- <View style={styles.anatomyColumns}>
930
- {anatomyColumns(anatomy.groups, 3).map((column, i) => (
931
- <View key={i} style={styles.anatomyColumn}>
932
- {column.map((group, j) => (
933
- <AnatomyGroup key={j} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
934
- ))}
935
- </View>
936
- ))}
937
- </View>
938
- </View>
939
- ) : (
940
- <View style={styles.anatomyBody}>
941
- <View style={styles.anatomyStage}>{anatomy.picture}</View>
942
- <View style={styles.anatomyLegend}>
943
- {anatomy.groups.map((group, i) => (
944
- <AnatomyGroup key={i} group={group} byId={byId} outline={outline} resolvesAddress={resolvesAddress} />
945
- ))}
946
- </View>
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>
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.14.1",
3
+ "version": "0.14.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",