@lotics/ui 24.0.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/MIGRATION.md CHANGED
@@ -139,10 +139,10 @@ Porting, by what the slot held:
139
139
 
140
140
  // AFTER — a value in a row, in whatever section the reference belongs to.
141
141
  // It wears the field surface, carries its go-to verb INSIDE, and peeks its
142
- // facts on press; detach lives in the peek. `tpl_record`'s `ReferenceValue`
143
- // is the worked composition.
142
+ // facts on press; detach lives in the peek. `ReferenceField` is the kit
143
+ // primitive; `tpl_record` § General is the worked composition.
144
144
  <DetailRow label="Customer">
145
- <ReferenceValue name={c.name} code={c.code} facts={…} onOpen={open} onRemove={detach} />
145
+ <ReferenceField name={c.name} code={c.code} facts={…} openLabel={`Open ${c.name}`} onOpen={open} onRemove={detach} />
146
146
  </DetailRow>
147
147
  ```
148
148
 
package/docs/catalog.md CHANGED
@@ -825,7 +825,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
825
825
  like the editors above and below it), whose press opens a PEEK of that record's facts.
826
826
  `name` + optional `code` + `facts` (label/value pairs) + `onOpen`/`openLabel` and an
827
827
  optional `onRemove`. The verbs live INSIDE the peek, never on the field: `Open` goes to
828
- the referenced record's own page, `Remove` detaches. The value is a `TextLink` marker
828
+ the referenced record's own page, `Remove` detaches both come from the locale pack
829
+ (`referenceField.open` / `.remove`), while `openLabel` stays a prop because it names the
830
+ DESTINATION for a screen reader, which only the caller knows. The value is a `TextLink` marker
829
831
  (underlined, neutral) because the press opens a summary rather than navigating. A
830
832
  reference is a FIELD, not a section — one pointer to one other row never earns a heading
831
833
  and a rail entry. Worked example: [`tpl_record`](../examples/tpl_record.tsx) § General
@@ -1051,7 +1053,9 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1051
1053
  rail item is navigation, never a selected option);
1052
1054
  section wrappers must be DIRECT children of the ScrollView content. The highlight and the
1053
1055
  jump both re-measure the live DOM on web, so content loading in above a section never
1054
- strands them. On NARROW containers
1056
+ strands them. Reaching the END of the scroll activates the LAST key regardless of offsets —
1057
+ a final section shorter than the viewport never scrolls its top past the trigger line, so
1058
+ without that it could never be the active one. On NARROW containers
1055
1059
  the rail becomes a PINNED bar naming the CURRENT section that opens a full-page
1056
1060
  section-picker `Modal` — never a horizontal tab strip. Worked example:
1057
1061
  [`tpl_record`](../examples/tpl_record.tsx).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "24.0.0",
3
+ "version": "24.0.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/locale.tsx CHANGED
@@ -37,6 +37,11 @@ export interface LoticsLocale {
37
37
  pagination: Required<PaginationLabels>;
38
38
  /** `SortHeader` a11y prefix + asc/desc suffixes. */
39
39
  sortHeader: Required<SortHeaderLabels>;
40
+ /** `ReferenceField`'s peek footer — the go-to and detach verbs. `openLabel`
41
+ * stays a per-instance PROP because it names the DESTINATION ("Open
42
+ * customer"), which is a11y text only a caller knows; these two are the
43
+ * VISIBLE chrome, which the pack owns. */
44
+ referenceField: { open: string; remove: string };
40
45
  /** `OptionList` (and everything built on it — `Select`, `Combobox`, the
41
46
  * in-cell editors): the select-all/deselect-all links, the empty state, the
42
47
  * internal search-field placeholder, and the `Combobox` recents header. */
@@ -215,6 +220,7 @@ export const en: LoticsLocale = {
215
220
  ascending: ", ascending",
216
221
  descending: ", descending",
217
222
  },
223
+ referenceField: { open: "Open", remove: "Remove" },
218
224
  optionList: { selectAll: "Select all", deselectAll: "Deselect all", clear: "Clear", noResults: "No results", recent: "Recent", searchPlaceholder: "Search…" },
219
225
  datePicker: { today: "Today", now: "Now", clear: "Clear", done: "Done", openCalendar: "Open calendar", time: "Time", startTime: "Start time", endTime: "End time", startDate: "Start date", endDate: "End date", addTime: "Add time", removeTime: "Remove time", year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM", invalidDate: "Enter a complete date", invalidTime: "Enter a complete time" },
220
226
  calendar: { previousMonth: "Previous month", nextMonth: "Next month" },
@@ -373,6 +379,7 @@ export const vi: LoticsLocale = {
373
379
  ascending: " (tăng dần)",
374
380
  descending: " (giảm dần)",
375
381
  },
382
+ referenceField: { open: "Mở", remove: "Gỡ" },
376
383
  optionList: { selectAll: "Chọn tất cả", deselectAll: "Bỏ chọn tất cả", clear: "Xóa", noResults: "Không có kết quả", recent: "Gần đây", searchPlaceholder: "Tìm…" },
377
384
  datePicker: { today: "Hôm nay", now: "Bây giờ", clear: "Xóa", done: "Xong", openCalendar: "Mở lịch", time: "Giờ", startTime: "Giờ bắt đầu", endTime: "Giờ kết thúc", startDate: "Ngày bắt đầu", endDate: "Ngày kết thúc", addTime: "Thêm giờ", removeTime: "Bỏ giờ", year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH", invalidDate: "Nhập ngày đầy đủ", invalidTime: "Nhập giờ đầy đủ" },
378
385
  calendar: { previousMonth: "Tháng trước", nextMonth: "Tháng sau" },
@@ -9,6 +9,7 @@ import { DialogSectionHeadingTitle } from "./section_heading";
9
9
  import { Text } from "./text";
10
10
  import { TextButton } from "./text_button";
11
11
  import { TextLink } from "./text_link";
12
+ import { useLoticsLocale } from "./locale";
12
13
 
13
14
  /**
14
15
  * A REFERENCE to another record, rendered as a FIELD VALUE.
@@ -63,6 +64,9 @@ export interface ReferenceFieldProps {
63
64
 
64
65
  export function ReferenceField(props: ReferenceFieldProps) {
65
66
  const { name, code, facts, accessibilityLabel, onOpen, openLabel, onRemove } = props;
67
+ // The peek's two verbs are the component's OWN chrome, so they come from the
68
+ // pack — hardcoding them shipped "Open"/"Remove" into every localized app.
69
+ const t = useLoticsLocale().referenceField;
66
70
  const anchor = useRef<View>(null);
67
71
  const [peekOpen, setPeekOpen] = useState(false);
68
72
  return (
@@ -159,13 +163,13 @@ export function ReferenceField(props: ReferenceFieldProps) {
159
163
  // the quiet/destructive verb at text weight beside a filled primary.
160
164
  // No icon: an ✕ beside "Remove" says it twice, and the glyph alone
161
165
  // made this read as the popover's dismiss rather than a decision.
162
- <TextButton color="danger" onPress={() => { setPeekOpen(false); onRemove(); }}>Remove</TextButton>
166
+ <TextButton color="danger" onPress={() => { setPeekOpen(false); onRemove(); }}>{t.remove}</TextButton>
163
167
  ) : null}
164
168
  <View style={{ flex: 1 }} />
165
169
  {/* `openLabel` names the DESTINATION ("Open customer"): a page carries
166
170
  four of these peeks, and four buttons announcing a bare "Open"
167
171
  are four controls a screen reader cannot tell apart. */}
168
- <Button title="Open" color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
172
+ <Button title={t.open} color="secondary" accessibilityLabel={openLabel} onPress={() => { setPeekOpen(false); onOpen(); }} />
169
173
  </View>
170
174
  </View>
171
175
  </PopoverContent>
@@ -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 };