@lotics/ui 47.13.1 → 47.15.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.
@@ -23,14 +23,12 @@ import { Section, SectionHeading, SectionHeadingTitle, SectionHeadingMeta, Subse
23
23
  import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
24
24
  import { Checklist, ChecklistActions, ChecklistGroup, ChecklistItem, ChecklistNote } from "@lotics/ui/checklist";
25
25
  import { DateStamp } from "@lotics/ui/date_stamp";
26
- import { MenuButton } from "@lotics/ui/menu_button";
27
- import { Popover, PopoverContent } from "@lotics/ui/popover";
26
+ import { SectionNav } from "@lotics/ui/section_nav";
28
27
  import { useSectionNav } from "@lotics/ui/use_section_nav";
29
28
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
30
29
  import { formatMoney } from "@lotics/ui/format_money";
31
30
  import { RecordSummary } from "@lotics/ui/record_summary";
32
31
  import { SummaryLine } from "@lotics/ui/summary_line";
33
- import { Badge } from "@lotics/ui/badge";
34
32
  import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline";
35
33
  import { useLoticsLocale } from "@lotics/ui/locale";
36
34
  import { Skeleton } from "@lotics/ui/skeleton";
@@ -2662,26 +2660,10 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2662
2660
  // since a reference is a field); everything after it is supplementary
2663
2661
  // (discussion, work, documents, money, lifecycle).
2664
2662
  //
2665
- // THE SECTION NAV IS HAND-ROLLED ON PURPOSE, for now. `useSectionNav` is the kit
2666
- // half the scroll-spy, `activeKey`, `jumpTo`, unit-tested RN-free and the
2667
- // CHROME below is this template's. It has exactly one consumer, and the kit's bar
2668
- // is the SECOND hand-roll, so lifting it today would ship an abstraction shaped by
2669
- // a single surface. `ReferenceField` sat here under this same note until a
2670
- // register's drawer needed it, and that is what let the extraction land on the
2671
- // PEEK contract instead of on the layout.
2672
- //
2673
- // When a second surface wants this, the contract worth lifting is: one section
2674
- // list, rendered as a RAIL where the container affords a gutter and as a pinned
2675
- // CURRENT-SECTION bar where it does not, the switch reading the CONTAINER (never
2676
- // the window); the picker anchored to the control that opens it, never a Modal
2677
- // that escapes its container; and the per-section attention dot, which is what
2678
- // makes the rail answer "where is the problem" and not merely "where am I".
2679
- //
2680
- // What is NOT the contract, and would poison it if lifted along: the issue-tone
2681
- // MAP below (its keys are this record's domain), the comments toggle (drawer
2682
- // chrome), the BackButton (page chrome), the gutter arithmetic (28 page / 20
2683
- // drawer, minus the trigger's padding) and the `wide` threshold. One consumer
2684
- // cannot tell those apart from the contract — two can.
2663
+ // `SectionNav` draws both forms; `useSectionNav` spies the scroll. What stays
2664
+ // HERE is what reads this record's domain or this page's layout: the issue map
2665
+ // below (its keys are this record's), the BackButton, the gutter arithmetic
2666
+ // and the `wide` threshold.
2685
2667
  const SECTIONS = [
2686
2668
  // PROGRESS leads. The header already IDENTIFIES the record — it titles the
2687
2669
  // customer and carries the code and city beneath it — so the first section
@@ -2694,7 +2676,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2694
2676
  //
2695
2677
  // The rail lists sections in PAGE order, or the scroll-spy highlights one
2696
2678
  // entry while the reader is looking at another.
2697
- { key: "progress", label: "Progress", icon: "git-branch" },
2679
+ { key: "progress", label: "Progress", icon: "trending-up" },
2698
2680
  { key: "activity", label: "Activity", icon: "message-square" },
2699
2681
  { key: "general", label: "General", icon: "file-text" },
2700
2682
  // Only a SECTION when the page is too narrow to seat the panel — so the wide
@@ -2749,15 +2731,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2749
2731
  transport: scheduleNote !== "on-time" ? "warning" : undefined,
2750
2732
  billing: customer === null ? "warning" : undefined,
2751
2733
  };
2752
- // Narrow: the rail becomes a pinned bar naming the CURRENT section; tapping
2753
- // it opens a full-page section picker (Escape / the close control dismiss).
2754
- const [sectionsOpen, setSectionsOpen] = useState(false);
2755
- // The picker anchors to the BAR BUTTON, so it is scoped to whatever container
2756
- // that button sits in — the page, or a drawer. A Modal escaped the container
2757
- // and took the whole window, which inside a drawer covered the register behind
2758
- // it. `triggerRef` (not `PopoverTrigger`) because the trigger IS a Button and a
2759
- // button must not nest in another pressable.
2760
- const sectionsAnchor = useRef<View>(null);
2761
2734
  const [pageWidth, setPageWidth] = useState<number | null>(null);
2762
2735
  // THREE COLUMNS: the outline rail in the left gutter, the record, and the
2763
2736
  // discussion panel in the right. The right gutter used to be empty — reserved
@@ -2853,63 +2826,21 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
2853
2826
  <View style={{ flex: 1, backgroundColor: colors.white, opacity: pageWidth == null ? 0 : 1 }} onLayout={(e) => setPageWidth(e.nativeEvent.layout.width)}>
2854
2827
  {pageWidth == null || wide ? null : (
2855
2828
  // Narrow: a pinned bar names the CURRENT section (the spy keeps it
2856
- // honest while scrolling); tapping opens the full-page section picker —
2857
- // no tab strip the thumb has to scroll.
2858
- // 18, not 12: the bar's gutter is derived from the CONTENT's (28) minus the
2859
- // trigger's own horizontal padding (10), so the section NAME starts on the
2860
- // same left edge as every section title below it. A fill-less button shows
2861
- // no box, so its ink is what the eye lines up 18 + 10 = 28. Set the bar,
2862
- // not a negative margin on the button: the gutter belongs to the bar, and
2863
- // one number here cannot drift out of step with itself.
2864
- <View style={{ borderBottomWidth: 1, borderBottomColor: colors.zinc[200], paddingHorizontal: (isDrawer ? DRAWER_GUTTER : 28) - 10, paddingVertical: 6, flexDirection: "row", alignItems: "center", gap: 8 }}>
2865
- {/* narrow keeps the standard too — the bare back glyph beside the
2866
- section bar (the rail that would carry it is collapsed). A DRAWER
2867
- already has one in its own header; two would be two exits. */}
2868
- {isDrawer ? null : <BackButton onPress={() => {}} />}
2869
- <Popover open={sectionsOpen} onOpenChange={setSectionsOpen} triggerRef={sectionsAnchor} side="bottom" align="start">
2870
- <View ref={sectionsAnchor}>
2871
- {/* MUTED, not `secondary`: this names WHERE YOU ARE, so it is a
2872
- position readout that happens to be pressable, and a filled chip
2873
- competes with the record's own actions for the eye. The usual cost
2874
- of a fill-less button does not apply here — it is chrome in a bar
2875
- with its own gutter, not a verb aligned to a column of text, so
2876
- there is nothing below it for its ink to line up with. The glyph
2877
- carries the affordance: `list-collapse` says WHAT the list is,
2878
- where `Switcher` uses a chevron for the same shape. */}
2879
- <Button
2880
- title={SECTIONS.find((sec) => sec.key === nav.activeKey)?.label ?? "Sections"}
2881
- color="muted"
2882
- onPress={() => setSectionsOpen(true)}
2883
- />
2884
- </View>
2885
- <PopoverContent style={{ width: 248 }} disableBodyScroll>
2886
- <View style={{ gap: 2 }}>
2887
- {SECTIONS.map((sec) => (
2888
- <MenuButton
2889
- key={sec.key}
2890
- icon={sec.icon}
2891
- title={sec.label}
2892
- current={nav.activeKey === sec.key}
2893
- onPress={() => {
2894
- setSectionsOpen(false);
2895
- nav.jumpTo(sec.key);
2896
- }}
2897
- />
2898
- ))}
2899
- </View>
2900
- </PopoverContent>
2901
- </Popover>
2902
- <View style={{ flex: 1 }} />
2903
- {/* WHERE it sits carries the meaning: sections on the left (where you
2904
- ARE), the discussion on the right (what is being SAID) — the same
2905
- two poles the wide layout puts in its two gutters. */}
2906
- {/* A LABEL with its count, not a bare glyph: this is one of the two
2907
- things the body can show, so it has to say which — and how much is
2908
- waiting, which is what decides whether you look now. In drawer chrome
2909
- it toggles the body; on a narrow page it still opens the sheet, which
2910
- is that layout's panel. The pair reads as a switch: whichever side the
2911
- body is showing wears the filled `secondary`. */}
2912
- </View>
2829
+ // honest while scrolling); tapping opens the section picker — no tab
2830
+ // strip the thumb has to scroll. The bar's gutter is the CONTENT's, and
2831
+ // `SectionNav` takes the trigger's own padding off it so the section
2832
+ // NAME starts on the same left edge as every section title below.
2833
+ //
2834
+ // Narrow keeps the back standard too, EXCEPT in a drawer, which already
2835
+ // has one in its own header two would be two exits.
2836
+ <SectionNav
2837
+ items={SECTIONS.map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
2838
+ activeKey={nav.activeKey}
2839
+ onJump={nav.jumpTo}
2840
+ wide={false}
2841
+ barGutter={isDrawer ? DRAWER_GUTTER : 28}
2842
+ back={isDrawer ? undefined : <BackButton onPress={() => {}} />}
2843
+ />
2913
2844
  )}
2914
2845
  <View style={{ flex: 1 }}>
2915
2846
  {/* FULL-WIDTH scroller: the whole page scrolls, wherever the pointer is. */}
@@ -5239,23 +5170,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
5239
5170
  scrolls, and out of the scroll row so the page stays wheel-scrollable
5240
5171
  edge to edge. */}
5241
5172
  {pageWidth == null || !wide ? null : (
5242
- <View style={{ position: "absolute", top: 28, left: railLeft, width: RAIL_W, gap: 10 }}>
5243
- {/* THE PANEL STANDARD: it opens with BACK — the circular glyph, the
5244
- collection it returns to named beside it (the register route in a
5245
- real app), visually apart from the outline items. */}
5246
- <BackButton label="Records" onPress={() => {}} />
5247
- <View style={{ gap: 2 }}>
5248
- {SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => (
5249
- <MenuButton
5250
- key={sec.key}
5251
- icon={sec.icon}
5252
- title={sec.label}
5253
- current={activeSection === sec.key}
5254
- right={sectionIssue[sec.key] ? <Badge variant="dot" color={sectionIssue[sec.key] === "danger" ? "red" : "amber"} /> : undefined}
5255
- onPress={() => nav.jumpTo(sec.key)}
5256
- />
5257
- ))}
5258
- </View>
5173
+ <View style={{ position: "absolute", top: 28, left: railLeft }}>
5174
+ <SectionNav
5175
+ /* Comments is a section only while the page is too narrow to seat the
5176
+ panel, so the wide rail drops it rather than offering a jump to
5177
+ something that is not there. */
5178
+ items={SECTIONS.filter((sec) => sec.key !== "comments").map((sec) => ({ ...sec, issue: sectionIssue[sec.key] }))}
5179
+ activeKey={activeSection}
5180
+ onJump={nav.jumpTo}
5181
+ wide
5182
+ railWidth={RAIL_W}
5183
+ back={<BackButton label="Records" onPress={() => {}} />}
5184
+ />
5259
5185
  </View>
5260
5186
  )}
5261
5187
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.13.1",
3
+ "version": "47.15.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -71,6 +71,7 @@
71
71
  "./data_grid": "./src/data_grid.tsx",
72
72
  "./date_calendar": "./src/date_calendar.tsx",
73
73
  "./date_filter": "./src/date_filter.tsx",
74
+ "./date_filter_presets": "./src/date_filter_presets.ts",
74
75
  "./date_picker": "./src/date_picker.tsx",
75
76
  "./date_range_filter_field": "./src/date_range_filter_field.tsx",
76
77
  "./date_stamp": "./src/date_stamp.tsx",
@@ -189,6 +190,8 @@
189
190
  "./markdown_types": "./src/markdown_types.ts",
190
191
  "./remark_gfm_safe": "./src/remark_gfm_safe.ts",
191
192
  "./matrix": "./src/matrix.tsx",
193
+ "./month_math": "./src/month_math.ts",
194
+ "./month_stepper": "./src/month_stepper.tsx",
192
195
  "./matrix_totals": "./src/matrix_totals.ts",
193
196
  "./media_player": {
194
197
  "react-native": "./src/media_player.tsx",
@@ -209,6 +212,7 @@
209
212
  "./option_badge": "./src/option_badge.tsx",
210
213
  "./option_list": "./src/option_list.tsx",
211
214
  "./option_picker": "./src/option_picker.tsx",
215
+ "./organization_name": "./src/organization_name.ts",
212
216
  "./overlay_layer": "./src/overlay_layer.ts",
213
217
  "./overlay_scope": "./src/overlay_scope.ts",
214
218
  "./page_content": "./src/page_content.tsx",
@@ -243,6 +247,7 @@
243
247
  "./search_match": "./src/search_match.ts",
244
248
  "./section_card": "./src/section_card.tsx",
245
249
  "./section_heading": "./src/section_heading.tsx",
250
+ "./section_nav": "./src/section_nav.tsx",
246
251
  "./section_stack": "./src/section_stack.tsx",
247
252
  "./segmented_control": "./src/segmented_control.tsx",
248
253
  "./select": "./src/select.tsx",
@@ -267,6 +272,7 @@
267
272
  "./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
268
273
  "./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
269
274
  "./status_badge": "./src/status_badge.tsx",
275
+ "./state_matrix": "./src/state_matrix.tsx",
270
276
  "./status_grid": "./src/status_grid.tsx",
271
277
  "./step_progress": "./src/step_progress.tsx",
272
278
  "./stepper": "./src/stepper.tsx",
@@ -295,6 +301,8 @@
295
301
  "./time_options": "./src/time_options.ts",
296
302
  "./time_picker": "./src/time_picker.tsx",
297
303
  "./timeline": "./src/timeline.tsx",
304
+ "./timetable": "./src/timetable.tsx",
305
+ "./toggle_strip": "./src/toggle_strip.tsx",
298
306
  "./tokens": "./src/tokens.ts",
299
307
  "./tooltip": "./src/tooltip.tsx",
300
308
  "./trend_chip": "./src/trend_chip.tsx",
@@ -48,14 +48,15 @@ export const AVATAR_PX: Record<AvatarSize, number> = {
48
48
  * beside it. It was 48, which anchored to nothing: the bar's other controls
49
49
  * are 40, and a 48px circle among them reads as the one thing that got out.
50
50
  *
51
- * **In a register, the rung follows what the row IS ABOUT, not the density.**
52
- * Where a person is one FIELD of the row an owner, an assignee beside a
53
- * dozen other columns the figure is a value like any other and takes `md`.
54
- * Where the person is the row's SUBJECT (a league table of reps, a roster,
55
- * anything grouped BY member), the avatar is that row's leading figure and
56
- * takes this rung; at `md` such a register reads as a list of numbers that
57
- * happens to carry faces. Budget for it: this rung is 12px wider than `md`,
58
- * and in a table it takes those 12 from the flexible column the name is in. */
51
+ * **In a register, the rung follows what the reader AIMS AT, not the
52
+ * density.** The row's subject takes this rung and so does an ORGANIZATION
53
+ * in its own column, because a book of movements is scanned by whose they
54
+ * are, and at `md` such a register reads as a list of numbers that happens to
55
+ * carry faces. `md` is for a mark incidental to the value the reader came for
56
+ * a person in an owner or assignee column, where the face is competing with
57
+ * the row's own mark anyway. Budget for it: this rung is 12px wider than
58
+ * `md`, and in a table it takes those 12 from the flexible column the name is
59
+ * in, so a column too tight to seat one takes `md`. */
59
60
  lg: 40,
60
61
  /** A profile's own header. */
61
62
  xl: 72,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The gridline VALUES a magnitude axis prints — the "nice" round numbers under
3
+ * the largest bar, chosen so the axis lands on about five of them.
4
+ *
5
+ * The rule belongs to the axis rather than to one chart, and it has one clause
6
+ * that is easy to miss: **an axis over whole things has no half.** A count of
7
+ * vehicles, contracts, trips or people cannot be 0,5, so a step finer than 1
8
+ * prints gridlines at quantities the data can never take — and it happens
9
+ * exactly where the numbers are smallest, because a max of 2 is where a
10
+ * five-tick target starts reaching below the unit. The reader is then asked to
11
+ * read "one and a half contracts", and the axis has stated something false
12
+ * about the subject rather than about the layout. So the candidate steps are
13
+ * filtered by what the DATA is made of: all-integer values take integer steps.
14
+ */
15
+
16
+ /** The multipliers that make a step read as round rather than arbitrary. */
17
+ const NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
18
+
19
+ const TARGET_TICKS = 5;
20
+
21
+ export function axisTicks(values: number[]): number[] {
22
+ const maxValue = Math.max(...values, 1);
23
+ const integral = values.every((v) => Number.isInteger(v));
24
+ const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
25
+
26
+ const usable = (step: number) => step > 0 && (!integral || Number.isInteger(step));
27
+
28
+ let bestStep = magnitude;
29
+ let bestTickCount = Infinity;
30
+
31
+ for (const multiplier of NICE_MULTIPLIERS) {
32
+ const step = multiplier * magnitude;
33
+ if (!usable(step)) continue;
34
+ const tickCount = Math.ceil(maxValue / step) + 1;
35
+ if (Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)) {
36
+ bestStep = step;
37
+ bestTickCount = tickCount;
38
+ }
39
+ }
40
+
41
+ // A finer pass, so a max of 168 is not left on gridlines a hundred apart. It
42
+ // is bounded at both ends — three to eight ticks — because this is the pass
43
+ // that can run away, and for whole things it is also where the halves came
44
+ // from.
45
+ for (const multiplier of NICE_MULTIPLIERS) {
46
+ const step = multiplier * (magnitude / 10);
47
+ if (!usable(step)) continue;
48
+ const tickCount = Math.ceil(maxValue / step) + 1;
49
+ if (
50
+ tickCount >= 3 &&
51
+ tickCount <= 8 &&
52
+ Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)
53
+ ) {
54
+ bestStep = step;
55
+ bestTickCount = tickCount;
56
+ }
57
+ }
58
+
59
+ const stepsNeeded = Math.ceil(maxValue / bestStep);
60
+ const ticks: number[] = [];
61
+ for (let i = 0; i <= stepsNeeded; i++) ticks.push(i * bestStep);
62
+ return ticks;
63
+ }
package/src/bar_chart.tsx CHANGED
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { axisLabelIndices } from "./axis_label_indices";
6
+ import { axisTicks } from "./axis_ticks";
6
7
  import { useLoticsLocale } from "./locale";
7
8
 
8
9
  const DEFAULT_BAR_COLOR = colors.blue[500];
@@ -18,6 +19,20 @@ const AXIS_LABEL_WIDTH = 56;
18
19
  * second literal there would silently stop matching the row it measures. */
19
20
  const LABEL_GAP = 8;
20
21
 
22
+ /** The horizontal orientation's fixed value column. Named because the label
23
+ * column's cap is computed from it, and a second literal there would stop
24
+ * matching the row it is measuring against. */
25
+ const VALUE_COLUMN_WIDTH = 96;
26
+
27
+ /** What the PLOT keeps whatever the label column asked for. A bar chart states
28
+ * proportion; under this the bars sit on their own 2px floor and the picture
29
+ * claims every row is equal. */
30
+ const MIN_TRACK_WIDTH = 72;
31
+
32
+ /** And what the label column keeps in return — a mark and a few characters,
33
+ * so the rows can still be told apart. */
34
+ const MIN_LABEL_WIDTH = 56;
35
+
21
36
  const defaultFormatNumber = (n: number): string =>
22
37
  new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
23
38
 
@@ -60,45 +75,6 @@ export interface BarChartProps {
60
75
  height?: number;
61
76
  }
62
77
 
63
- function useAxisTicks(maxValue: number) {
64
- return useMemo(() => {
65
- const targetTickCount = 5;
66
- const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
67
- const niceMultipliers = [1, 2, 2.5, 5, 10];
68
- let bestStep = magnitude;
69
- let bestTickCount = Infinity;
70
-
71
- for (const multiplier of niceMultipliers) {
72
- const step = multiplier * magnitude;
73
- const tickCount = Math.ceil(maxValue / step) + 1;
74
- if (Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)) {
75
- bestStep = step;
76
- bestTickCount = tickCount;
77
- }
78
- }
79
-
80
- for (const multiplier of niceMultipliers) {
81
- const step = multiplier * (magnitude / 10);
82
- const tickCount = Math.ceil(maxValue / step) + 1;
83
- if (
84
- tickCount >= 3 &&
85
- tickCount <= 8 &&
86
- Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)
87
- ) {
88
- bestStep = step;
89
- bestTickCount = tickCount;
90
- }
91
- }
92
-
93
- const stepsNeeded = Math.ceil(maxValue / bestStep);
94
- const ticks: number[] = [];
95
- for (let i = 0; i <= stepsNeeded; i++) {
96
- ticks.push(i * bestStep);
97
- }
98
- return ticks;
99
- }, [maxValue]);
100
- }
101
-
102
78
  /**
103
79
  * The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
104
80
  * `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
@@ -119,8 +95,10 @@ export function BarChart(props: BarChartProps) {
119
95
  height: barAreaHeight = 200,
120
96
  } = props;
121
97
  const maxValue = Math.max(...data.map((d) => d.value), 1);
122
- const axisTicks = useAxisTicks(maxValue);
123
- const axisMax = axisTicks[axisTicks.length - 1] || maxValue;
98
+ // The gridlines come from the VALUES, not from their maximum, because an axis
99
+ // over whole things must not print a half — see `axis_ticks`.
100
+ const ticks = useMemo(() => axisTicks(data.map((d) => d.value)), [data]);
101
+ const axisMax = ticks[ticks.length - 1] || maxValue;
124
102
 
125
103
  // The vertical axis prints as many labels as the row fits and thins the rest —
126
104
  // the same rule `LineChart` runs, because it is the axis's rule and not one
@@ -132,6 +110,45 @@ export function BarChart(props: BarChartProps) {
132
110
  const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
133
111
  setLabelsWidth(event.nativeEvent.layout.width);
134
112
  }, []);
113
+
114
+ // THE HORIZONTAL ORIENTATION IS MEASURED TOO, AND FOR TWO REASONS AT ONCE.
115
+ //
116
+ // Its label column and its value column are FIXED, so a narrow container
117
+ // spends its whole width on chrome and leaves the plot a few pixels: every
118
+ // bar then renders at its own minimum, and a row of identical stubs is a
119
+ // picture stating that every entity is equal. Measured at 375, a chart with a
120
+ // 184px label column had a 5px track. So `labelWidth` is a REQUEST, never a
121
+ // reservation — it gives way before the plot does, because a trimmed name
122
+ // beside a readable bar still says more than a full name beside a stub.
123
+ //
124
+ // The value axis under it thins by the categorical rule: its ticks are
125
+ // positioned by FRACTION, so a narrow track does not crowd them, it stacks
126
+ // them — five money labels on a short track paint one unreadable smudge, and
127
+ // nothing overflows, errors or clips while it happens. The tick MARKS stay: a
128
+ // 1px line collides with nothing, and it is the label that could not fit.
129
+ const [rowWidth, setRowWidth] = useState(0);
130
+ const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
131
+ setRowWidth(event.nativeEvent.layout.width);
132
+ }, []);
133
+ const horizontal = useMemo(() => {
134
+ const chrome = VALUE_COLUMN_WIDTH + LABEL_GAP * 2;
135
+ const label =
136
+ rowWidth <= 0
137
+ ? labelWidth
138
+ : Math.min(labelWidth, Math.max(MIN_LABEL_WIDTH, rowWidth - chrome - MIN_TRACK_WIDTH));
139
+ const track = rowWidth <= 0 ? 0 : rowWidth - label - chrome;
140
+ return {
141
+ labelWidth: label,
142
+ // Evenly-spaced ticks from 0 to the axis maximum sit at `i / (count - 1)`
143
+ // — the POINT geometry `axisLabelIndices` already serves for a line's x
144
+ // axis, anchored on the last tick because that is the one setting scale.
145
+ // Keyed on the MEASUREMENT, not on the track: unmeasured shows every
146
+ // label (thinning is what measurement buys), while a measured track of
147
+ // zero is a real answer and `axisLabelIndices` returns the one label that
148
+ // states the scale.
149
+ keptTicks: rowWidth <= 0 ? null : new Set(axisLabelIndices(ticks.length, Math.max(track, 0))),
150
+ };
151
+ }, [rowWidth, labelWidth, ticks.length]);
135
152
  // ONE derivation from the ONE measurement — which labels survive AND how wide
136
153
  // the box each survivor gets. They are two answers about the same row, and a
137
154
  // second pass over the same numbers is exactly how they came to disagree about
@@ -193,14 +210,14 @@ export function BarChart(props: BarChartProps) {
193
210
  if (orientation === "horizontal") {
194
211
  return (
195
212
  <View style={styles.chartContainer}>
196
- <View style={styles.horizontalChart}>
213
+ <View style={styles.horizontalChart} onLayout={handleRowLayout}>
197
214
  {data.map((item, index) => {
198
215
  const widthPercent = (item.value / axisMax) * 100;
199
216
  const barColor = item.color ?? DEFAULT_BAR_COLOR;
200
217
 
201
218
  return (
202
219
  <View key={index} style={styles.horizontalBarContainer}>
203
- <View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
220
+ <View style={[styles.horizontalBarLabel, { width: horizontal.labelWidth }]}>
204
221
  {item.leading !== undefined ? item.leading : null}
205
222
  <Text size="sm" numberOfLines={1}>
206
223
  {item.label}
@@ -226,17 +243,19 @@ export function BarChart(props: BarChartProps) {
226
243
  );
227
244
  })}
228
245
  <View style={styles.horizontalAxisContainer}>
229
- <View style={{ width: labelWidth }} />
246
+ <View style={{ width: horizontal.labelWidth }} />
230
247
  <View style={styles.horizontalAxisTrack}>
231
- {axisTicks.map((tick, index) => (
248
+ {ticks.map((tick, index) => (
232
249
  <View
233
250
  key={index}
234
251
  style={[styles.horizontalAxisTick, { left: `${(tick / axisMax) * 100}%` }]}
235
252
  >
236
253
  <View style={styles.tickMark} />
237
- <Text size="xs" color="muted">
238
- {formatNumber(tick)}
239
- </Text>
254
+ {horizontal.keptTicks === null || horizontal.keptTicks.has(index) ? (
255
+ <Text size="xs" color="muted">
256
+ {formatNumber(tick)}
257
+ </Text>
258
+ ) : null}
240
259
  </View>
241
260
  ))}
242
261
  </View>
@@ -252,7 +271,7 @@ export function BarChart(props: BarChartProps) {
252
271
  <View style={styles.verticalChartWrapper}>
253
272
  <View style={styles.verticalChartRow}>
254
273
  <View style={[styles.verticalAxis, { height: barAreaHeight }]}>
255
- {axisTicks
274
+ {ticks
256
275
  .slice()
257
276
  .reverse()
258
277
  .map((tick, index) => (
@@ -411,7 +430,7 @@ const styles = StyleSheet.create({
411
430
  flex: 1,
412
431
  },
413
432
  horizontalBarValue: {
414
- width: 96,
433
+ width: VALUE_COLUMN_WIDTH,
415
434
  alignItems: "flex-end",
416
435
  },
417
436
  horizontalBar: {
@@ -1,7 +1,7 @@
1
1
  import { useMemo, useState, type ReactNode } from "react";
2
2
  import { View, StyleSheet, type LayoutChangeEvent } from "react-native";
3
3
  import { colors } from "../colors";
4
- import { useLoticsLocale } from "../locale";
4
+ import { useLoticsLocale, useLocaleTag } from "../locale";
5
5
  import { CalendarProvider, useCalendar, type CalendarContextValue } from "./context";
6
6
  import { CalendarToolbar, CALENDAR_TOOLBAR_HEIGHT } from "./calendar_toolbar";
7
7
  import { CalendarMonth, MIN_MONTH_HEIGHT } from "./month_view";
@@ -33,6 +33,8 @@ export interface CalendarViewProps<T = unknown> {
33
33
  /** Which modes the built-in toolbar offers. Default: all four. */
34
34
  views?: CalendarViewMode[];
35
35
  weekStartsOn?: Weekday;
36
+ /** BCP-47 tag for the weekday / month / time names. Defaults to the active
37
+ * `LoticsLocaleProvider` pack's `bcp47`; pass one only to override it. */
36
38
  locale?: string;
37
39
  /** Per-instance overrides; the rest resolve from `LoticsLocale.calendarView`. */
38
40
  labels?: Partial<CalendarViewLabels>;
@@ -88,13 +90,17 @@ export interface CalendarViewProps<T = unknown> {
88
90
  */
89
91
  export function CalendarView<T = unknown>(props: CalendarViewProps<T>) {
90
92
  const {
91
- events, weekStartsOn = 1, locale, views = ALL_VIEWS,
93
+ events, weekStartsOn = 1, views = ALL_VIEWS,
92
94
  dayStartHour = 0, dayEndHour = 24, compactBelow = DEFAULT_COMPACT_BELOW, now,
93
95
  onEventPress, onSlotPress, renderEvent, children,
94
96
  } = props;
95
97
 
96
98
  const pack = useLoticsLocale();
97
99
  const labels: CalendarViewLabels = { ...pack.calendarView, ...props.labels };
100
+ // Every Intl rendering the grid does (weekday and month names, the times down
101
+ // the day axis) reads this, so the active pack alone localizes the calendar —
102
+ // an app that already supplies `vi` must not also thread a `locale` prop.
103
+ const locale = useLocaleTag(props.locale);
98
104
 
99
105
  // One reading of the clock for the whole tree, so the badge, the now line and
100
106
  // the scroll target cannot disagree across a midnight boundary mid-render.