@lotics/ui 42.0.0 → 42.4.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.
@@ -0,0 +1,217 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { View, StyleSheet } from "react-native";
3
+ import { Metric, type MetricFormat, type MetricTone } from "./metric";
4
+ import { StackedProgressBar } from "./stacked_progress_bar";
5
+ import { SummaryLine, type SummaryLineItem } from "./summary_line";
6
+ import { useLocaleTag } from "./locale";
7
+ import { Text } from "./text";
8
+
9
+ /**
10
+ * One bucket of the set being summarized — a slice of the whole, with the colour
11
+ * it wears wherever it appears.
12
+ *
13
+ * The buckets PARTITION the set: their values sum to the total, which is why the
14
+ * total is derived rather than passed. A count that belongs to the same rows but
15
+ * does not partition them — a condition rather than a position, "overdue" beside
16
+ * "draft / sent / paid" — is not a bucket and must not be one, or the bar starts
17
+ * claiming a share for something that is already inside another segment. Pass
18
+ * those to `Summary.Facts` as `extra`.
19
+ */
20
+ export interface SummaryBucket {
21
+ key: string;
22
+ /** Reads AFTER the value, like every other label in this family. */
23
+ label: string;
24
+ value: number;
25
+ /** The segment's colour, and the swatch's. Use the same value this bucket
26
+ * wears in the rest of the product — a bar that disagrees with the badge
27
+ * beside it is worse than no bar. */
28
+ color: string;
29
+ /** What this bucket means — the ⓘ beside its count. */
30
+ info?: string;
31
+ }
32
+
33
+ interface SummaryContext {
34
+ buckets: readonly SummaryBucket[];
35
+ total: number;
36
+ }
37
+
38
+ const Ctx = createContext<SummaryContext | null>(null);
39
+
40
+ function useSummary(part: string): SummaryContext {
41
+ const ctx = useContext(Ctx);
42
+ if (!ctx) throw new Error(`<Summary.${part}> must be rendered inside <Summary>.`);
43
+ return ctx;
44
+ }
45
+
46
+ interface SummaryProps {
47
+ buckets: readonly SummaryBucket[];
48
+ children: ReactNode;
49
+ }
50
+
51
+ /**
52
+ * The register's summary band — a headline figure, the set's DISTRIBUTION, and
53
+ * the buckets named — composed from parts rather than configured by props.
54
+ *
55
+ * **Why a compound and not one component with more props.** The parts encode a
56
+ * correspondence that is easy to break and impossible to see broken: the bar,
57
+ * the legend and the headline all describe ONE set. Passing a `segments` array
58
+ * beside an `items` array beside a `total` invites exactly three drifts —
59
+ * segments reordered against their labels, a legend swatch picking a different
60
+ * shade, and a hardcoded total that stops summing after a bucket is added. So
61
+ * the buckets are declared ONCE on the root and every part derives from them.
62
+ * The total is a sum. The swatches are the segments' own colours. Nothing is
63
+ * restated, so nothing can disagree.
64
+ *
65
+ * That correspondence is the reason this is a primitive at all — this kit does
66
+ * not extract a composition for saving lines of layout.
67
+ *
68
+ * Reach for it when the set has a SHAPE worth showing. A summary that is only a
69
+ * few aggregates stays `SummaryLine`, which is this band's `Facts` row on its
70
+ * own; a boxed dashboard stat band is `KPIStrip`; a drill-down facet browser
71
+ * over a large population is `Breakdown`.
72
+ *
73
+ * <Summary buckets={STAGES}>
74
+ * <Summary.Header>
75
+ * <Summary.Total label="khách hàng" />
76
+ * <Summary.Metric value={660_000_000} label="hoa hồng" format="currency" compact />
77
+ * </Summary.Header>
78
+ * <Summary.Distribution />
79
+ * <Summary.Facts extra={[{ label: "đang tắc", value: 3, tone: "warning" }]} />
80
+ * </Summary>
81
+ */
82
+ export function Summary(props: SummaryProps) {
83
+ const { buckets, children } = props;
84
+ const total = buckets.reduce((sum, b) => sum + b.value, 0);
85
+ return (
86
+ <Ctx.Provider value={{ buckets, total }}>
87
+ <View style={styles.root}>{children}</View>
88
+ </Ctx.Provider>
89
+ );
90
+ }
91
+
92
+ /** The band's top line: a `Total` at the start, anything else pushed to the end.
93
+ * Baseline-aligned, because figures of different sizes sitting on one line is
94
+ * the whole reason this row exists. */
95
+ function Header(props: { children: ReactNode }) {
96
+ return <View style={styles.header}>{props.children}</View>;
97
+ }
98
+
99
+ /**
100
+ * The set's SIZE, at display scale — the band's headline.
101
+ *
102
+ * The number is the sum of the buckets and is never passed in. A page that
103
+ * restates its own total eventually restates it wrongly: a bucket gets added,
104
+ * the constant does not move, and the headline quietly disagrees with the bar
105
+ * directly beneath it.
106
+ */
107
+ function Total(props: { label: string; formatValue?: (n: number) => string }) {
108
+ const { total } = useSummary("Total");
109
+ const tag = useLocaleTag();
110
+ // Grouped by the reader's locale by default — a set of 12,345 was rendering
111
+ // "12345" in the one place on the screen sized to be read first, because the
112
+ // first app to use this counted in the hundreds. `formatValue` is the escape
113
+ // hatch for a total whose unit is not a plain count.
114
+ const shown = props.formatValue ? props.formatValue(total) : total.toLocaleString(tag);
115
+ return (
116
+ <View style={styles.pair}>
117
+ <Text size="xxxl" weight="semibold" tabular>
118
+ {shown}
119
+ </Text>
120
+ <Text size="md" color="muted">
121
+ {props.label}
122
+ </Text>
123
+ </View>
124
+ );
125
+ }
126
+
127
+ /**
128
+ * A second headline figure that is NOT part of the distribution — a sum of money
129
+ * over the same rows, an average, a rate.
130
+ *
131
+ * Deliberately not a bucket: it does not partition the set, so the bar must not
132
+ * carry it. It sits on the header row because a reader opening a register asks
133
+ * two questions ("how much of this is there" and "what is it worth"), and the
134
+ * answers belong side by side.
135
+ */
136
+ function SummaryMetric(props: {
137
+ value: number | string | null | undefined;
138
+ label: string;
139
+ format?: MetricFormat;
140
+ currency?: string;
141
+ compact?: boolean;
142
+ emptyLabel?: string;
143
+ tone?: MetricTone;
144
+ }) {
145
+ const { value, label, ...rest } = props;
146
+ return (
147
+ <View style={styles.pair}>
148
+ <Metric value={value} size="lg" {...rest} />
149
+ <Text size="sm" color="muted">
150
+ {label}
151
+ </Text>
152
+ </View>
153
+ );
154
+ }
155
+
156
+ /**
157
+ * The set's shape — one segment per bucket, in declaration order.
158
+ *
159
+ * Order is the CALLER's, untouched, because for an ordered set (a pipeline, a
160
+ * ladder, an age bracket) the sequence carries meaning that sorting by size
161
+ * would destroy. Re-rank the array before passing it if size is what matters.
162
+ */
163
+ function Distribution(props: { height?: number }) {
164
+ const { buckets, total } = useSummary("Distribution");
165
+ return (
166
+ <StackedProgressBar
167
+ height={props.height ?? 20}
168
+ total={total}
169
+ segments={buckets.map((b) => ({ key: b.key, value: b.value, color: b.color }))}
170
+ />
171
+ );
172
+ }
173
+
174
+ /**
175
+ * The buckets named, with their counts — the band's legend, and on its own the
176
+ * whole of `SummaryLine`.
177
+ *
178
+ * Each item takes its swatch from its own bucket, so the legend cannot drift
179
+ * from the bar above it. `extra` appends aggregates that are NOT segments and
180
+ * therefore get no swatch — the visual difference is the point: a reader can see
181
+ * that the unswatched figure is not a slice of the bar.
182
+ */
183
+ function Facts(props: { extra?: readonly SummaryLineItem[] }) {
184
+ const { buckets } = useSummary("Facts");
185
+ const items: SummaryLineItem[] = buckets.map((b) => ({
186
+ label: b.label,
187
+ value: b.value,
188
+ color: b.color,
189
+ info: b.info,
190
+ }));
191
+ return <SummaryLine items={[...items, ...(props.extra ?? [])]} />;
192
+ }
193
+
194
+ Summary.Header = Header;
195
+ Summary.Total = Total;
196
+ Summary.Metric = SummaryMetric;
197
+ Summary.Distribution = Distribution;
198
+ Summary.Facts = Facts;
199
+
200
+ const styles = StyleSheet.create({
201
+ // 12, and it is half of what a page must put BETWEEN this band and its
202
+ // neighbours. The band's parts are one group — a figure, the shape of what it
203
+ // counts, and that shape's key — so they have to sit closer to each other than
204
+ // the band sits to the register below it. Measured at 14 against a 16px page
205
+ // gap, the ratio was 1.14: every block on the screen an equal peer, nothing
206
+ // grouped, which is what "no rhythm" is when you measure it.
207
+ root: { gap: 12 },
208
+ header: {
209
+ flexDirection: "row",
210
+ alignItems: "baseline",
211
+ flexWrap: "wrap",
212
+ justifyContent: "space-between",
213
+ columnGap: 20,
214
+ rowGap: 8,
215
+ },
216
+ pair: { flexDirection: "row", alignItems: "baseline", gap: 8 },
217
+ });
@@ -20,6 +20,21 @@ export interface SummaryLineItem {
20
20
  trend?: number | null;
21
21
  /** What this metric means — the ⓘ next to the value opens it in a popover. */
22
22
  info?: string;
23
+ /**
24
+ * Swatch colour, when this line is ALSO the legend for a chart above it —
25
+ * typically a `StackedProgressBar` whose segments are these same aggregates.
26
+ *
27
+ * It exists so a bar can be labelled without giving up what this component
28
+ * already carries. `LegendItem` annotates a chart but holds no `info`, so
29
+ * reaching for it costs the popovers that explain what a bucket includes and
30
+ * what it must not be added to — and those explanations are the reason several
31
+ * of these lines are trustworthy. One swatch here keeps identity off colour
32
+ * alone, which is what a legend is for.
33
+ *
34
+ * Pass the SAME value the segment uses. Omit it and the item renders exactly
35
+ * as it always has.
36
+ */
37
+ color?: string;
23
38
  }
24
39
 
25
40
  export interface SummaryLineProps {
@@ -55,6 +70,7 @@ export function SummaryLine(props: SummaryLineProps) {
55
70
  <View style={styles.row}>
56
71
  {items.map((item) => (
57
72
  <View key={item.label} style={styles.item}>
73
+ {item.color ? <View style={[styles.swatch, { backgroundColor: item.color }]} /> : null}
58
74
  <Metric
59
75
  value={item.value}
60
76
  format={item.format}
@@ -89,4 +105,11 @@ const styles = StyleSheet.create({
89
105
  alignItems: "center",
90
106
  gap: 6,
91
107
  },
108
+ // Matches `LegendItem`'s swatch, because the two annotate the same charts and
109
+ // a reader should not be able to tell which component drew the legend.
110
+ swatch: {
111
+ width: 8,
112
+ height: 8,
113
+ borderRadius: 2,
114
+ },
92
115
  });
package/src/theme.tsx CHANGED
@@ -1,61 +1,24 @@
1
- import { createContext, useContext, type ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
+ import { LoticsThemeContext, type LoticsTheme } from "./theme_context";
2
3
 
3
- /**
4
- * Default platform accent — refined OKLCH blue. Used by chart fills,
5
- * focus rings, and any primitive that asks "what's the brand color".
6
- * Apps that need a different accent wrap their root in `LoticsThemeProvider`.
7
- *
8
- * Why OKLCH instead of hex? Perceptual uniformity — `oklch(0.6 0.118 250)`
9
- * sits at the same perceptual lightness/saturation as the `oklch(0.6 0.118
10
- * 184.704)` (teal) chị's workspace uses, just shifted in hue. Hex shifts
11
- * lightness as hue rotates and the eye picks it up as inconsistency.
12
- */
13
- export const DEFAULT_ACCENT = "oklch(0.6 0.118 250)";
14
-
15
- interface LoticsTheme {
16
- /** Single brand accent. Chart fills, hero CTAs, focus rings. */
17
- accent: string;
18
- }
19
-
20
- const LoticsThemeContext = createContext<LoticsTheme>({ accent: DEFAULT_ACCENT });
4
+ export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
21
5
 
22
- interface LoticsThemeProviderProps {
23
- /** Brand accent. Overrides the platform default. Accepts any CSS color
24
- * value (OKLCH recommended, hex / hsl also fine). */
25
- accent?: string;
6
+ interface LoticsThemeProviderProps extends LoticsTheme {
26
7
  children: ReactNode;
27
8
  }
28
9
 
29
10
  /**
30
- * App-root provider that supplies brand tokens to @lotics/ui primitives.
31
- * Wrap your top-level app element to override the platform defaults:
11
+ * App-root provider the NATIVE half.
32
12
  *
33
- * // src/main.tsx
34
- * <LoticsThemeProvider accent="oklch(0.6 0.118 184.704)">
35
- * <App />
36
- * </LoticsThemeProvider>
13
+ * On native there are no CSS variables, and `colors` is a literal table, so
14
+ * there is nothing to declare: the provider publishes the values for anything
15
+ * that reads them through `useLoticsTheme()` and renders its children
16
+ * untouched. The web half (`theme.web.tsx`) is where a theme actually paints.
37
17
  *
38
- * Components that consume theme tokens use `useLoticsTheme()`. Each
39
- * primitive also accepts a per-instance `color` prop for one-off
40
- * customization without needing a different provider.
41
- *
42
- * Scope is intentionally narrow — accent only. Semantic colors (success,
43
- * danger) already work via existing `colors.green[600]` etc. Adding more
44
- * theme tokens requires a real product reason.
18
+ * Deliberately NOT a `View`: a wrapper here would insert a layout box into every
19
+ * themed app's tree on the one platform that gains nothing from it.
45
20
  */
46
21
  export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
47
- const accent = props.accent ?? DEFAULT_ACCENT;
48
- return (
49
- <LoticsThemeContext.Provider value={{ accent }}>
50
- {props.children}
51
- </LoticsThemeContext.Provider>
52
- );
53
- }
54
-
55
- /**
56
- * Read the current theme. Primitives that need the accent color call this
57
- * hook; apps don't need it directly (use the provider's prop instead).
58
- */
59
- export function useLoticsTheme(): LoticsTheme {
60
- return useContext(LoticsThemeContext);
22
+ const { children, ...theme } = props;
23
+ return <LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>;
61
24
  }
@@ -0,0 +1,64 @@
1
+ import { useMemo, type CSSProperties, type ReactNode } from "react";
2
+ import { LoticsThemeContext, THEME_VARS, type LoticsTheme } from "./theme_context";
3
+
4
+ export { DEFAULT_ACCENT, useLoticsTheme, THEME_VARS, type LoticsTheme } from "./theme_context";
5
+
6
+ interface LoticsThemeProviderProps extends LoticsTheme {
7
+ children: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * App-root provider that gives `@lotics/ui` primitives this app's identity:
12
+ *
13
+ * // src/main.tsx
14
+ * <LoticsThemeProvider accent="#0F766E" border="#E3E8E6">
15
+ * <App />
16
+ * </LoticsThemeProvider>
17
+ *
18
+ * It declares the CSS custom properties `colors.web.ts` reads, so every kit
19
+ * component below re-paints without knowing the provider exists. That
20
+ * indirection is the point: the alternative — a context each primitive
21
+ * subscribes to — would have to be threaded through 136 modules and would turn
22
+ * their static stylesheets into per-render inline styles.
23
+ *
24
+ * **It renders a plain `div`, not a `View`, and that is load-bearing.**
25
+ * react-native-web's style compiler only emits properties it knows; a custom
26
+ * property handed to a `View` is silently DROPPED, so the variables never reach
27
+ * the DOM and a themed app renders in the platform defaults with no error
28
+ * anywhere. React DOM, by contrast, writes `--*` inline style keys through
29
+ * verbatim. The failure is invisible rather than loud, which is exactly why the
30
+ * two platforms get two files instead of one clever component.
31
+ *
32
+ * Only a role actually PASSED is declared, so an omitted one leaves its variable
33
+ * undefined and `colors.web.ts`'s inline fallback answers. That is what makes
34
+ * theming additive: a partial theme overrides a part, never resetting the rest
35
+ * to some second set of defaults.
36
+ */
37
+ export function LoticsThemeProvider(props: LoticsThemeProviderProps) {
38
+ const { children, ...theme } = props;
39
+
40
+ // Keyed on the four values, not the object: the call site is normally an
41
+ // inline literal, which would otherwise rebuild this style — and re-render
42
+ // every kit surface under it — on each parent render.
43
+ const style = useMemo(() => {
44
+ const vars: Record<string, string> = {};
45
+ for (const [role, name] of Object.entries(THEME_VARS)) {
46
+ const value = theme[role as keyof LoticsTheme];
47
+ if (value !== undefined) vars[name] = value;
48
+ }
49
+ // `display: contents` — the provider generates NO box. Custom properties
50
+ // inherit down the DOM tree rather than the box tree, so the variables still
51
+ // reach every descendant while the element itself adds no layout at all.
52
+ // The first version was a flex column with `flex: 1`, which silently imposed
53
+ // a layout contract on every app that wrapped its root in this: a provider
54
+ // whose entire job is declaring three strings has no business deciding how its
55
+ // children stack.
56
+ return { display: "contents", ...vars } as CSSProperties;
57
+ }, [theme.accent, theme.background, theme.border]);
58
+
59
+ return (
60
+ <div style={style}>
61
+ <LoticsThemeContext.Provider value={theme}>{children}</LoticsThemeContext.Provider>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,45 @@
1
+ import { createContext, useContext } from "react";
2
+ import { colors } from "./color_tokens";
3
+
4
+ /**
5
+ * The platform's own accent — what every kit surface wears unthemed. Same value
6
+ * `colors.accent` falls back to; named so an app can reference the default
7
+ * explicitly instead of re-typing a literal.
8
+ */
9
+ export const DEFAULT_ACCENT = colors.accent;
10
+
11
+ /** The roles an app may own. See `color_tokens.ts` for why exactly these. */
12
+ export interface LoticsTheme {
13
+ /** The one brand hue. It paints IDENTITY marks — today the avatar's initials
14
+ * disc — and deliberately NOT the focus ring or the primary action, which stay
15
+ * neutral so those read the same in every app. Never a status, a series or a
16
+ * valence: those come from a palette family and carry meaning. */
17
+ accent?: string;
18
+ /** The surface Card, Drawer and Modal paint — 21 sites in the kit. Left white
19
+ * on almost every app; set it only for a deliberately toned surface. */
20
+ background?: string;
21
+ /** Hairlines — 48 sites in the kit, and a register is mostly these lines, so a
22
+ * small change here is felt across a whole screen. */
23
+ border?: string;
24
+ }
25
+
26
+ /**
27
+ * The CSS custom properties `colors.web.ts` reads. Part of the published
28
+ * contract: an app may set them from its own stylesheet instead of the provider.
29
+ */
30
+ export const THEME_VARS: Record<keyof LoticsTheme, string> = {
31
+ accent: "--lotics-accent",
32
+ background: "--lotics-background",
33
+ border: "--lotics-border",
34
+ };
35
+
36
+ export const LoticsThemeContext = createContext<LoticsTheme>({});
37
+
38
+ /**
39
+ * Read the current theme. An app SETS values through the provider's props; this
40
+ * is for a surface that must branch on one (a chart picking a fill). A role the
41
+ * app did not set reads `undefined` — ask `colors.<role>` for what will paint.
42
+ */
43
+ export function useLoticsTheme(): LoticsTheme {
44
+ return useContext(LoticsThemeContext);
45
+ }