@lotics/ui 23.2.0 → 24.0.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/src/text_link.tsx CHANGED
@@ -1,39 +1,49 @@
1
1
  import { Text, type TextProps } from "./text";
2
2
  import { Icon, type IconName } from "./icon";
3
+ import { colors } from "./colors";
3
4
  import { getTextColor } from "./text_utils";
4
5
 
5
6
  export interface TextLinkProps extends TextProps {
6
7
  /** Optional leading icon. */
7
8
  icon?: IconName;
8
9
  /** A URL — renders a real anchor (`<a href>` on web; middle-click / open-in-new-tab
9
- * work). For in-app routing, wrap `TextLink` or use `onPress`. */
10
+ * work). For in-app routing, wrap `TextLink` in your own pressable. */
10
11
  href?: string;
11
- /** An in-app action. With NEITHER `href` nor `onPress`, `TextLink` is just
12
- * underlined text you wrap in your own pressable (e.g. a table cell). */
13
- onPress?: () => void;
14
12
  }
15
13
 
16
14
  /**
17
- * Underlined text that is OPTIONALLY a link (`href` a web anchor) or an action
18
- * (`onPress` pressable); with neither it's plain underlined text you drop inside
19
- * your own pressable (a table cell, a custom row). Colour comes from `color` (it
20
- * inherits every `Text` prop) — the neutral, configurable counterpart to `Link`,
21
- * which is fixed blue + `role="link"` for the "this opens somewhere else" signal.
15
+ * Underlined text that NAVIGATES `href` renders a real anchor; with no `href`
16
+ * it is plain underlined text you drop inside your own pressable (a table cell, a
17
+ * custom row), or a MARKER that a value leads somewhere (a record reference).
18
+ * Colour comes from `color` (it inherits every `Text` prop) — the neutral,
19
+ * configurable counterpart to `Link`, which is fixed blue + `role="link"` for the
20
+ * "this opens somewhere else" signal.
21
+ *
22
+ * It does NOT act. An `onPress` here used to resolve to `role="button"`, so one
23
+ * export was three components — a link, a button, and a passive marker — behind
24
+ * one name, inside `OptionList`, `FilterChip` and others. That mode is
25
+ * `TextButton` now — also underlined, since that is what marks a surface-less
26
+ * control interactive, but in ZINC: blue is reserved for navigation, so the ink is
27
+ * what says whether a press moves you or acts. Pick by what the press DOES.
22
28
  */
23
29
  export function TextLink(props: TextLinkProps) {
24
- const { icon, href, onPress, children, color, style, ...textProps } = props;
25
- const interactive = href != null || onPress != null;
30
+ const { icon, href, children, color, style, ...textProps } = props;
26
31
  return (
27
32
  <Text
28
33
  decoration="underline"
29
34
  weight="medium"
30
35
  color={color}
31
36
  href={href}
32
- onPress={onPress}
33
- accessibilityRole={href != null ? "link" : onPress != null ? "button" : undefined}
37
+ accessibilityRole={href != null ? "link" : undefined}
34
38
  style={[
35
39
  icon ? { display: "flex", flexDirection: "row", alignItems: "center", gap: 4 } : null,
36
- interactive ? { cursor: "pointer" } : null,
40
+ // Blue only when it actually NAVIGATES. An `href` is a destination, so it
41
+ // takes the navigation ink (`color` still overrides). Without one this is
42
+ // underlined text you wrap yourself, or a MARKER on a value that leads
43
+ // somewhere — the record's customer field is one, and pressing it opens a
44
+ // PEEK, so blue there would promise a trip it never takes.
45
+ href != null && color == null ? { color: colors.blue[600] } : null,
46
+ href != null ? { cursor: "pointer" } : null,
37
47
  style,
38
48
  ]}
39
49
  {...textProps}
@@ -14,6 +14,14 @@ export interface SectionNavHandle<K extends string> {
14
14
  onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
15
15
  }
16
16
 
17
+ /**
18
+ * How close to the content's end still counts as "at the end". Fractional
19
+ * scroll offsets (zoom, hi-dpi, momentum) land a pixel or two short of the
20
+ * exact bottom, and a strict equality would drop the rule on the frame that
21
+ * matters most — the one the reader stops on.
22
+ */
23
+ const END_SLACK = 2;
24
+
17
25
  /**
18
26
  * Scroll-spy for a LONG record surface with a left outline rail: one scrolling
19
27
  * page whose sections register their offsets, a rail of `MenuButton`s that
@@ -39,6 +47,10 @@ export interface SectionNavHandle<K extends string> {
39
47
  * grid, a late-loading table) can never stale either. On native there is no
40
48
  * re-measure — content above should settle before precision matters
41
49
  * (`onLayout` won't refire on a pure position shift).
50
+ *
51
+ * The LAST key is active whenever the scroll reaches the end, regardless of
52
+ * offsets: a final section shorter than the viewport never scrolls its top past
53
+ * the trigger line, so without that rule the rail can never point at it.
42
54
  */
43
55
  export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): SectionNavHandle<K> {
44
56
  const scrollRef = useRef<ScrollView>(null);
@@ -93,13 +105,31 @@ export function useSectionNav<K extends string>(keys: readonly [K, ...K[]]): Sec
93
105
  scrollRef.current?.scrollTo({ y: Math.max(0, y - 12), animated: true });
94
106
  };
95
107
  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
96
- const y = e.nativeEvent.contentOffset.y + 80;
108
+ const { contentOffset, layoutMeasurement, contentSize } = e.nativeEvent;
109
+ const y = contentOffset.y + 80;
97
110
  const offsets = measure();
98
111
  let cur: K = keys[0];
99
112
  for (const k of keys) {
100
113
  const sy = offsets[k];
101
114
  if (sy != null && sy <= y) cur = k;
102
115
  }
116
+ // THE LAST SECTION IS OTHERWISE UNREACHABLE. A section opening within one
117
+ // viewport of the content's end can never scroll its top past the trigger
118
+ // line: at maximum scroll `y` is `contentHeight - viewportHeight + 80`,
119
+ // which is still ABOVE it, so the rule above pins the rail to the section
120
+ // before it and the reader watches the wrong item stay lit while they read.
121
+ // A trailing spacer would "fix" it by making every record end in blank
122
+ // space, which is paying in layout for an arithmetic problem.
123
+ //
124
+ // At the end of the scroll there is nothing left to disambiguate — the
125
+ // reader is as deep into the last section as the page permits — so the end
126
+ // of the scroll IS the last section. Guarded on both measurements being
127
+ // present: RN gives them on every scroll event, but a caller synthesising
128
+ // one cannot be assumed to, and without them "at the end" is unknowable.
129
+ if (layoutMeasurement != null && contentSize != null) {
130
+ const remaining = contentSize.height - (contentOffset.y + layoutMeasurement.height);
131
+ if (remaining <= END_SLACK) cur = keys[keys.length - 1];
132
+ }
103
133
  if (cur !== activeKey) setActiveKey(cur);
104
134
  };
105
135
  return { scrollRef, activeKey, register, jumpTo, onScroll };