@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.
package/src/drawer.tsx CHANGED
@@ -104,6 +104,30 @@ export function Drawer(props: DrawerProps) {
104
104
  return () => document.removeEventListener("keydown", handler);
105
105
  }, [open, onPrev, onNext]);
106
106
 
107
+ // ONE definition for both placements. Written twice, the wide bar and the
108
+ // narrow line would drift the first time either gained a control.
109
+ const RecordNav = () => (
110
+ <View style={styles.nav}>
111
+ <IconButton
112
+ icon="chevron-left"
113
+ accessibilityLabel={loc.previous}
114
+ onPress={onPrev ?? (() => {})}
115
+ disabled={!onPrev}
116
+ />
117
+ {position ? (
118
+ <Text size="xs" color="muted" tabular>
119
+ {position}
120
+ </Text>
121
+ ) : null}
122
+ <IconButton
123
+ icon="chevron-right"
124
+ accessibilityLabel={loc.next}
125
+ onPress={onNext ?? (() => {})}
126
+ disabled={!onNext}
127
+ />
128
+ </View>
129
+ );
130
+
107
131
  // Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
108
132
  // `Modal`'s body-level div on first render and never re-orders it, so an
109
133
  // always-mounted overlay claims its slot ahead of one opened later and covers
@@ -117,36 +141,32 @@ export function Drawer(props: DrawerProps) {
117
141
  <Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel={loc.close} tabIndex={-1} />
118
142
  <View style={[styles.panel, { width: screenSize.small ? "100%" : width }]}>
119
143
  <PortalHost>
120
- <View style={styles.header}>
121
- {typeof title === "string" ? (
122
- <Text size="lg" weight="semibold" style={{ flex: 1 }}>
123
- {title}
124
- </Text>
125
- ) : (
126
- <View style={{ flex: 1 }}>{title}</View>
127
- )}
128
- {hasNav ? (
129
- <View style={styles.nav}>
130
- <IconButton
131
- icon="chevron-left"
132
- accessibilityLabel={loc.previous}
133
- onPress={onPrev ?? (() => {})}
134
- disabled={!onPrev}
135
- />
136
- {position ? (
137
- <Text size="xs" color="muted" tabular>
138
- {position}
139
- </Text>
140
- ) : null}
141
- <IconButton
142
- icon="chevron-right"
143
- accessibilityLabel={loc.next}
144
- onPress={onNext ?? (() => {})}
145
- disabled={!onNext}
146
- />
144
+ {/* THE TITLE IS THE LAST THING THE BAR GIVES UP.
145
+ It names the record whose file is open — and on one bar with a
146
+ pager and three icon buttons it is the only element that CAN
147
+ give way, because every one of those is a fixed width. At 375
148
+ that made the drawer cut its own subject ("Hộ kinh doanh Vận tải
149
+ Đức Thắng" needed 261px and got 206) while the chrome beside it
150
+ sat whole. So below the split width the record navigation drops
151
+ to its own line and the name takes the full bar; the close stays
152
+ up top, where a reader reaches for it. */}
153
+ <View style={[styles.header, screenSize.small && hasNav ? styles.headerStacked : null]}>
154
+ <View style={styles.headerTopLine}>
155
+ {typeof title === "string" ? (
156
+ <Text size="lg" weight="semibold" style={{ flex: 1 }}>
157
+ {title}
158
+ </Text>
159
+ ) : (
160
+ <View style={{ flex: 1, minWidth: 0 }}>{title}</View>
161
+ )}
162
+ {hasNav && !screenSize.small ? <RecordNav /> : null}
163
+ <IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
164
+ </View>
165
+ {hasNav && screenSize.small ? (
166
+ <View style={styles.navLine}>
167
+ <RecordNav />
147
168
  </View>
148
169
  ) : null}
149
- <IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
150
170
  </View>
151
171
  <SizeBoundary testID={testID} style={styles.body}>
152
172
  {children}
@@ -263,6 +283,24 @@ const styles = StyleSheet.create({
263
283
  borderLeftColor: colors.border,
264
284
  boxShadow: "-8px 0 24px rgba(0, 0, 0, 0.08)",
265
285
  },
286
+ // The narrow bar is a COLUMN: the title's line, then the record navigation
287
+ // under it. Two lines cost 32px; a cut record name costs the reader the one
288
+ // fact that says which file they are in.
289
+ headerStacked: {
290
+ flexDirection: "column",
291
+ alignItems: "stretch",
292
+ gap: 4,
293
+ },
294
+ headerTopLine: {
295
+ flexDirection: "row",
296
+ alignItems: "center",
297
+ gap: 8,
298
+ minWidth: 0,
299
+ },
300
+ navLine: {
301
+ flexDirection: "row",
302
+ justifyContent: "flex-start",
303
+ },
266
304
  header: {
267
305
  flexDirection: "row",
268
306
  alignItems: "center",
@@ -9,7 +9,8 @@ export type DateFormatStyle =
9
9
  | "medium" // 22 thg 5, 2026 / Sep 22, 2026 — readable, abbreviated month
10
10
  | "long" // 22 tháng 5, 2026 / September 22, 2026 — readable, full month
11
11
  | "dayMonth" // 22 thg 5 / Sep 22 — day + abbreviated month, no year
12
- | "monthYear"; // Tháng 5 2026 / May 2026 — a period label (sentence-cased)
12
+ | "monthYear" // Tháng 5 năm 2026 / May 2026 — a period label (sentence-cased)
13
+ | "quarterYear"; // Quý 2 năm 2026 / Q2 2026 — the quarter the value falls in
13
14
 
14
15
  export interface FormatDateOptions {
15
16
  /** The date style. Default "date". */
@@ -34,6 +35,20 @@ const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.D
34
35
  monthYear: { month: "long", year: "numeric" },
35
36
  };
36
37
 
38
+ /**
39
+ * How a quarter is SAID, by language.
40
+ *
41
+ * A table rather than `Intl`: CLDR carries no quarter pattern for
42
+ * `DateTimeFormat`, so there is nothing to ask it for. Every other language
43
+ * falls to the international "Q2 2026" — which is what an unlocalized surface
44
+ * should print, rather than a Vietnamese sentence it never asked for.
45
+ */
46
+ const QUARTER_YEAR: Record<string, (quarter: number, year: number) => string> = {
47
+ vi: (quarter, year) => `Quý ${quarter} năm ${year}`,
48
+ };
49
+
50
+ const defaultQuarterYear = (quarter: number, year: number): string => `Q${quarter} ${year}`;
51
+
37
52
  const ISO_YEAR = /^(\d{4})$/;
38
53
  const ISO_YEAR_MONTH = /^(\d{4})-(\d{2})$/;
39
54
  const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
@@ -104,7 +119,8 @@ export function toISODate(value: Date | string | null | undefined): string {
104
119
  * THE date-value formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
105
120
  * and returns a localized display string; defaults to the home market. `format` picks the date
106
121
  * style: `date` → `22/05/2026`; `medium` → `22 thg 5, 2026`; `long` → `22 tháng 5, 2026`;
107
- * `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 2026`. **`time: true` prepends the 24h time to
122
+ * `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 năm 2026`, `quarterYear` `Quý 2 năm 2026`
123
+ * (`Q2 2026` outside Vietnamese). **`time: true` prepends the 24h time to
108
124
  * ANY style** — `14:30 22/05/2026`, `14:30 22 tháng 5, 2026` (time-first). `compact` drops the
109
125
  * year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
110
126
  * `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
@@ -124,6 +140,16 @@ export function formatDate(value: Date | string | null | undefined, options: For
124
140
  if (precision === "year") {
125
141
  return String(date.getFullYear());
126
142
  }
143
+ // A quarter is read off the MONTH, so a month-precision value serves it
144
+ // exactly as a full date does — and, being a calendar period, it carries no
145
+ // day and no clock whatever `time` says.
146
+ if (format === "quarterYear") {
147
+ const language = locale.split("-")[0].toLowerCase();
148
+ return (QUARTER_YEAR[language] ?? defaultQuarterYear)(
149
+ Math.floor(date.getMonth() / 3) + 1,
150
+ date.getFullYear(),
151
+ );
152
+ }
127
153
  if (precision === "month") {
128
154
  return formatMonthYear(date, format, locale, compact, emptyLabel);
129
155
  }
@@ -166,7 +192,9 @@ export function formatDate(value: Date | string | null | undefined, options: For
166
192
  */
167
193
  function formatMonthYear(
168
194
  date: Date,
169
- format: DateFormatStyle,
195
+ // `quarterYear` never arrives here — it answers before precision is consulted,
196
+ // and it takes no month options to strip a day out of.
197
+ format: Exclude<DateFormatStyle, "quarterYear">,
170
198
  locale: string,
171
199
  compact: boolean,
172
200
  emptyLabel: string,
@@ -5,7 +5,7 @@ import { colors, solid } from "../colors";
5
5
  import { proportionalRadius } from "../control_surface";
6
6
  import { FocusRingPressable } from "../focus_ring_pressable";
7
7
  import { SegmentedControl, type SegmentOption } from "../segmented_control";
8
- import { useLoticsLocale } from "../locale";
8
+ import { useLoticsLocale, useLocaleTag } from "../locale";
9
9
  import { dayDiff } from "../calendar/dates";
10
10
  import { axisRange, barGeometry, buildRows, buildTicks, pxPerDay } from "./scale";
11
11
  import type { GanttLabels, GanttScale, GanttTask } from "./types";
@@ -23,6 +23,8 @@ export interface GanttViewProps<T = unknown> {
23
23
  defaultScale?: GanttScale;
24
24
  /** What counts as today for the marker and the opening scroll position. */
25
25
  today?: Date;
26
+ /** BCP-47 tag for the axis tick names. Defaults to the active
27
+ * `LoticsLocaleProvider` pack's `bcp47`; pass one only to override it. */
26
28
  locale?: string;
27
29
  /** Optional toolbar caption shown left of the zoom switch. */
28
30
  title?: string;
@@ -48,9 +50,11 @@ export interface GanttViewProps<T = unknown> {
48
50
  * Renders at its natural height — wrap in a `ScrollView` for very long lists.
49
51
  */
50
52
  export function GanttView<T = unknown>(props: GanttViewProps<T>) {
51
- const { tasks, defaultScale = "week", today = new Date(), locale, title, onTaskPress } = props;
53
+ const { tasks, defaultScale = "week", today = new Date(), title, onTaskPress } = props;
52
54
  const pack = useLoticsLocale();
53
55
  const L: GanttLabels = { ...pack.gantt, ...props.labels };
56
+ // The axis tick names come from Intl, so the active pack alone localizes them.
57
+ const locale = useLocaleTag(props.locale);
54
58
  const [scale, setScale] = useState<GanttScale>(defaultScale);
55
59
 
56
60
  const rows = useMemo(() => buildRows(tasks), [tasks]);
package/src/index.css CHANGED
@@ -351,6 +351,24 @@ html {
351
351
  outline: none;
352
352
  }
353
353
 
354
+ /* Native UA number-stepper reset — subtractive, like the focus reset above.
355
+ `NumberInput` renders `type="number"` for the numeric keypad and the parse,
356
+ and the browser throws in a stepper: two arrows that sit ON the right edge of
357
+ a right-aligned figure and take width out of the field, so a numeric column is
358
+ one width at rest and a narrower one the moment somebody focuses a cell.
359
+ Nothing in the kit steps a value by pressing an arrow — `Counter` draws its
360
+ own — so it is a control that costs layout and does nothing. */
361
+ input[type="number"]::-webkit-outer-spin-button,
362
+ input[type="number"]::-webkit-inner-spin-button {
363
+ -webkit-appearance: none;
364
+ margin: 0;
365
+ }
366
+
367
+ input[type="number"] {
368
+ -moz-appearance: textfield;
369
+ appearance: textfield;
370
+ }
371
+
354
372
  /* @font-face declarations are NOT included here — each app provides its own
355
373
  font loading because paths differ per platform:
356
374
  - Frontend: /fonts/Inter_*.woff2 (served from public/)
package/src/ledger.tsx CHANGED
@@ -256,24 +256,27 @@ export function LedgerRow(props: LedgerRowProps) {
256
256
  const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
257
257
  const rowDetails = useLoticsLocale().ledger.rowDetails;
258
258
  const { format, dropMeta } = useLedger();
259
- // The caption goes entirely rather than shrinking to nothing. Yielding first
260
- // (below) is the right ORDER but not a floor: at phone width a long caption
261
- // still claims most of the text budget, clips, and takes the label down with
262
- // it. Two separate
263
- // authors had already worked around this by dropping `meta` at small widths
264
- // in their own apps, which is the component's job.
259
+ // Below `META_MIN_ROW_WIDTH` the caption goes entirely rather than surviving as
260
+ // a sliver. Yielding first (see `yield`) is the right ORDER but not a floor: at
261
+ // phone width a caption clipped to two syllables is noise, and the space reads
262
+ // better spent on nothing. Two separate authors had already worked around this
263
+ // by dropping `meta` at small widths in their own apps, which is the
264
+ // component's job.
265
265
  const showMeta = meta != null && meta !== "" && !dropMeta;
266
266
  const content = (
267
267
  <>
268
268
  <Text size="sm" numberOfLines={1} style={styles.shrink}>
269
269
  {label}
270
270
  </Text>
271
+ {/* The caption IS the row's filler — it takes what the label and the figure
272
+ leave. With no caption to hold it open, an empty one does the same job. */}
271
273
  {showMeta ? (
272
274
  <Text size="xs" color="muted" numberOfLines={1} style={styles.yield}>
273
275
  {meta}
274
276
  </Text>
275
- ) : null}
276
- <View style={styles.grow} />
277
+ ) : (
278
+ <View style={styles.grow} />
279
+ )}
277
280
  {!peek && reference ? (
278
281
  <Link size="xs" onPress={reference.onPress} accessibilityLabel={reference.label}>
279
282
  {reference.label}
@@ -389,13 +392,18 @@ const styles = StyleSheet.create({
389
392
  rowHovered: { backgroundColor: colors.zinc[50] },
390
393
  grow: { flexGrow: 1, flexShrink: 1 },
391
394
  shrink: { flexShrink: 1 },
392
- // The META yields BEFORE the label, and by more. Only the label carried a
393
- // shrink, so under width pressure the row'''s IDENTITY was the one thing that
394
- // gave way while its subordinate qualifier held full width a narrow ledger
395
- // read "Tổng thu (chư…" beside an intact "03/06, Cash". A caption qualifies a
396
- // number; the label names it, and the name is what a reader needs when the
397
- // row is too tight for both.
398
- yield: { flexShrink: 3 },
395
+ // The META yields ENTIRELY before the label yields at all. A shrink RATIO (this
396
+ // was 3 against the label's 1) buys an order of magnitude, not an order of
397
+ // service: flexbox scales a shrink factor by the item's own width, so a caption
398
+ // longer than the label still handed part of the loss back to it, and a 375px
399
+ // ledger read "Ghi công / nằm trong số còn phải t…" both halves clipped,
400
+ // and the FIGURE squeezed along with them. Sizing the caption off the leftovers
401
+ // instead (`flexBasis: 0` + `flexGrow: 1`, so it is also the row's filler) makes
402
+ // the order exact: the label and the figure take their content width first, the
403
+ // caption gets what remains, and only once it is down to nothing does the label
404
+ // start clipping. A caption qualifies a number; the label names it, and the name
405
+ // is what a reader needs when the row is too tight for both.
406
+ yield: { flexGrow: 1, flexShrink: 1, flexBasis: 0 },
399
407
  total: { gap: 6 },
400
408
  // The BASIS row, the only one whose two sides can differ in height: its label may
401
409
  // carry a stacked `meta` while the figure stays one line. Centring then measures the