@multiplatform.one/components 7.81.1 → 7.82.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.
Files changed (187) hide show
  1. package/dist/cjs/charts/BarChart.cjs +1 -0
  2. package/dist/cjs/charts/BarChart.native.js +1 -0
  3. package/dist/cjs/charts/BarChart.native.js.map +1 -1
  4. package/dist/cjs/charts/ChartDataView.cjs +89 -9
  5. package/dist/cjs/charts/ChartDataView.native.js +94 -9
  6. package/dist/cjs/charts/ChartDataView.native.js.map +1 -1
  7. package/dist/cjs/charts/ChartSurface.cjs +26 -8
  8. package/dist/cjs/charts/ChartSurface.native.js +27 -9
  9. package/dist/cjs/charts/ChartSurface.native.js.map +1 -1
  10. package/dist/cjs/charts/chartHeight.cjs +1 -1
  11. package/dist/cjs/charts/chartHeight.native.js +1 -1
  12. package/dist/cjs/charts/chartHeight.native.js.map +1 -1
  13. package/dist/cjs/charts/composeChart.cjs +1 -1
  14. package/dist/cjs/charts/composeChart.native.js +1 -1
  15. package/dist/cjs/charts/composeChart.native.js.map +1 -1
  16. package/dist/cjs/code/CodeBlock/index.cjs +38 -31
  17. package/dist/cjs/code/CodeBlock/index.native.js +38 -31
  18. package/dist/cjs/code/CodeBlock/index.native.js.map +1 -1
  19. package/dist/cjs/code/LogView.cjs +142 -0
  20. package/dist/cjs/code/LogView.native.js +151 -0
  21. package/dist/cjs/code/LogView.native.js.map +1 -0
  22. package/dist/cjs/code/index.cjs +2 -1
  23. package/dist/cjs/code/index.native.js +1 -0
  24. package/dist/cjs/code/index.native.js.map +1 -1
  25. package/dist/cjs/layouts/EmptyState.cjs +9 -43
  26. package/dist/cjs/layouts/EmptyState.native.js +9 -45
  27. package/dist/cjs/layouts/EmptyState.native.js.map +1 -1
  28. package/dist/cjs/layouts/PageTemplates.cjs +61 -0
  29. package/dist/cjs/layouts/PageTemplates.native.js +62 -0
  30. package/dist/cjs/layouts/PageTemplates.native.js.map +1 -1
  31. package/dist/cjs/layouts/barTitleSpan.cjs +54 -0
  32. package/dist/cjs/layouts/barTitleSpan.native.js +78 -0
  33. package/dist/cjs/layouts/barTitleSpan.native.js.map +1 -0
  34. package/dist/cjs/layouts/page.cjs +130 -41
  35. package/dist/cjs/layouts/page.native.js +143 -45
  36. package/dist/cjs/layouts/page.native.js.map +1 -1
  37. package/dist/cjs/views/ImageGrid.cjs +9 -0
  38. package/dist/cjs/views/ImageGrid.native.js +12 -1
  39. package/dist/cjs/views/ImageGrid.native.js.map +1 -1
  40. package/dist/esm/charts/BarChart.mjs +1 -0
  41. package/dist/esm/charts/BarChart.mjs.map +1 -1
  42. package/dist/esm/charts/BarChart.native.js +1 -0
  43. package/dist/esm/charts/BarChart.native.js.map +1 -1
  44. package/dist/esm/charts/ChartDataView.mjs +83 -3
  45. package/dist/esm/charts/ChartDataView.mjs.map +1 -1
  46. package/dist/esm/charts/ChartDataView.native.js +88 -3
  47. package/dist/esm/charts/ChartDataView.native.js.map +1 -1
  48. package/dist/esm/charts/ChartSurface.mjs +26 -8
  49. package/dist/esm/charts/ChartSurface.mjs.map +1 -1
  50. package/dist/esm/charts/ChartSurface.native.js +27 -9
  51. package/dist/esm/charts/ChartSurface.native.js.map +1 -1
  52. package/dist/esm/charts/chartHeight.mjs +1 -1
  53. package/dist/esm/charts/chartHeight.mjs.map +1 -1
  54. package/dist/esm/charts/chartHeight.native.js +1 -1
  55. package/dist/esm/charts/chartHeight.native.js.map +1 -1
  56. package/dist/esm/charts/composeChart.mjs +1 -1
  57. package/dist/esm/charts/composeChart.mjs.map +1 -1
  58. package/dist/esm/charts/composeChart.native.js +1 -1
  59. package/dist/esm/charts/composeChart.native.js.map +1 -1
  60. package/dist/esm/code/CodeBlock/index.mjs +39 -32
  61. package/dist/esm/code/CodeBlock/index.mjs.map +1 -1
  62. package/dist/esm/code/CodeBlock/index.native.js +39 -32
  63. package/dist/esm/code/CodeBlock/index.native.js.map +1 -1
  64. package/dist/esm/code/LogView.mjs +117 -0
  65. package/dist/esm/code/LogView.mjs.map +1 -0
  66. package/dist/esm/code/LogView.native.js +123 -0
  67. package/dist/esm/code/LogView.native.js.map +1 -0
  68. package/dist/esm/code/index.mjs +1 -0
  69. package/dist/esm/code/index.mjs.map +1 -1
  70. package/dist/esm/code/index.native.js +1 -0
  71. package/dist/esm/code/index.native.js.map +1 -1
  72. package/dist/esm/layouts/EmptyState.mjs +9 -43
  73. package/dist/esm/layouts/EmptyState.mjs.map +1 -1
  74. package/dist/esm/layouts/EmptyState.native.js +9 -45
  75. package/dist/esm/layouts/EmptyState.native.js.map +1 -1
  76. package/dist/esm/layouts/PageTemplates.mjs +61 -1
  77. package/dist/esm/layouts/PageTemplates.mjs.map +1 -1
  78. package/dist/esm/layouts/PageTemplates.native.js +62 -1
  79. package/dist/esm/layouts/PageTemplates.native.js.map +1 -1
  80. package/dist/esm/layouts/barTitleSpan.mjs +27 -0
  81. package/dist/esm/layouts/barTitleSpan.mjs.map +1 -0
  82. package/dist/esm/layouts/barTitleSpan.native.js +48 -0
  83. package/dist/esm/layouts/barTitleSpan.native.js.map +1 -0
  84. package/dist/esm/layouts/page.mjs +132 -43
  85. package/dist/esm/layouts/page.mjs.map +1 -1
  86. package/dist/esm/layouts/page.native.js +145 -47
  87. package/dist/esm/layouts/page.native.js.map +1 -1
  88. package/dist/esm/views/ImageGrid.mjs +10 -1
  89. package/dist/esm/views/ImageGrid.mjs.map +1 -1
  90. package/dist/esm/views/ImageGrid.native.js +13 -2
  91. package/dist/esm/views/ImageGrid.native.js.map +1 -1
  92. package/dist/jsx/charts/BarChart.mjs +1 -0
  93. package/dist/jsx/charts/BarChart.mjs.map +1 -1
  94. package/dist/jsx/charts/BarChart.native.js +1 -0
  95. package/dist/jsx/charts/BarChart.native.js.map +1 -1
  96. package/dist/jsx/charts/ChartDataView.mjs +83 -3
  97. package/dist/jsx/charts/ChartDataView.mjs.map +1 -1
  98. package/dist/jsx/charts/ChartDataView.native.js +94 -9
  99. package/dist/jsx/charts/ChartDataView.native.js.map +1 -1
  100. package/dist/jsx/charts/ChartSurface.mjs +26 -8
  101. package/dist/jsx/charts/ChartSurface.mjs.map +1 -1
  102. package/dist/jsx/charts/ChartSurface.native.js +27 -9
  103. package/dist/jsx/charts/ChartSurface.native.js.map +1 -1
  104. package/dist/jsx/charts/chartHeight.mjs +1 -1
  105. package/dist/jsx/charts/chartHeight.mjs.map +1 -1
  106. package/dist/jsx/charts/chartHeight.native.js +1 -1
  107. package/dist/jsx/charts/chartHeight.native.js.map +1 -1
  108. package/dist/jsx/charts/composeChart.mjs +1 -1
  109. package/dist/jsx/charts/composeChart.mjs.map +1 -1
  110. package/dist/jsx/charts/composeChart.native.js +1 -1
  111. package/dist/jsx/charts/composeChart.native.js.map +1 -1
  112. package/dist/jsx/code/CodeBlock/index.mjs +39 -32
  113. package/dist/jsx/code/CodeBlock/index.mjs.map +1 -1
  114. package/dist/jsx/code/CodeBlock/index.native.js +38 -31
  115. package/dist/jsx/code/CodeBlock/index.native.js.map +1 -1
  116. package/dist/jsx/code/LogView.mjs +117 -0
  117. package/dist/jsx/code/LogView.mjs.map +1 -0
  118. package/dist/jsx/code/LogView.native.js +151 -0
  119. package/dist/jsx/code/LogView.native.js.map +1 -0
  120. package/dist/jsx/code/index.mjs +1 -0
  121. package/dist/jsx/code/index.mjs.map +1 -1
  122. package/dist/jsx/code/index.native.js +1 -0
  123. package/dist/jsx/code/index.native.js.map +1 -1
  124. package/dist/jsx/layouts/EmptyState.mjs +9 -43
  125. package/dist/jsx/layouts/EmptyState.mjs.map +1 -1
  126. package/dist/jsx/layouts/EmptyState.native.js +9 -45
  127. package/dist/jsx/layouts/EmptyState.native.js.map +1 -1
  128. package/dist/jsx/layouts/PageTemplates.mjs +61 -1
  129. package/dist/jsx/layouts/PageTemplates.mjs.map +1 -1
  130. package/dist/jsx/layouts/PageTemplates.native.js +62 -0
  131. package/dist/jsx/layouts/PageTemplates.native.js.map +1 -1
  132. package/dist/jsx/layouts/barTitleSpan.mjs +27 -0
  133. package/dist/jsx/layouts/barTitleSpan.mjs.map +1 -0
  134. package/dist/jsx/layouts/barTitleSpan.native.js +78 -0
  135. package/dist/jsx/layouts/barTitleSpan.native.js.map +1 -0
  136. package/dist/jsx/layouts/page.mjs +132 -43
  137. package/dist/jsx/layouts/page.mjs.map +1 -1
  138. package/dist/jsx/layouts/page.native.js +143 -45
  139. package/dist/jsx/layouts/page.native.js.map +1 -1
  140. package/dist/jsx/views/ImageGrid.mjs +10 -1
  141. package/dist/jsx/views/ImageGrid.mjs.map +1 -1
  142. package/dist/jsx/views/ImageGrid.native.js +12 -1
  143. package/dist/jsx/views/ImageGrid.native.js.map +1 -1
  144. package/package.json +9 -9
  145. package/src/charts/BarChart.tsx +8 -2
  146. package/src/charts/ChartDataView.spec.tsx +23 -2
  147. package/src/charts/ChartDataView.tsx +87 -6
  148. package/src/charts/ChartSurface.tsx +17 -8
  149. package/src/charts/chartHeight.ts +8 -2
  150. package/src/charts/composeChart.ts +3 -2
  151. package/src/code/CodeBlock/CodeBlock.copy.spec.tsx +13 -0
  152. package/src/code/CodeBlock/index.tsx +46 -30
  153. package/src/code/LogView.tsx +127 -0
  154. package/src/code/index.ts +1 -0
  155. package/src/layouts/DenseContent.stories.tsx +93 -0
  156. package/src/layouts/EmptyState.spec.tsx +2 -0
  157. package/src/layouts/EmptyState.tsx +6 -39
  158. package/src/layouts/PageHeader.stories.tsx +238 -38
  159. package/src/layouts/PageTemplates.tsx +90 -0
  160. package/src/layouts/barTitleSpan.spec.ts +35 -0
  161. package/src/layouts/barTitleSpan.ts +44 -0
  162. package/src/layouts/glyphCentre.spec.tsx +218 -0
  163. package/src/layouts/navBar.spec.tsx +58 -2
  164. package/src/layouts/page.tsx +126 -34
  165. package/src/views/ImageGrid.tsx +19 -2
  166. package/types/charts/BarChart.d.ts +1 -1
  167. package/types/charts/BarChart.d.ts.map +1 -1
  168. package/types/charts/ChartDataView.d.ts +3 -1
  169. package/types/charts/ChartDataView.d.ts.map +1 -1
  170. package/types/charts/ChartSurface.d.ts.map +1 -1
  171. package/types/charts/chartHeight.d.ts +2 -1
  172. package/types/charts/chartHeight.d.ts.map +1 -1
  173. package/types/charts/composeChart.d.ts +2 -1
  174. package/types/charts/composeChart.d.ts.map +1 -1
  175. package/types/code/CodeBlock/index.d.ts.map +1 -1
  176. package/types/code/LogView.d.ts +31 -0
  177. package/types/code/LogView.d.ts.map +1 -0
  178. package/types/code/index.d.ts +1 -0
  179. package/types/code/index.d.ts.map +1 -1
  180. package/types/layouts/EmptyState.d.ts.map +1 -1
  181. package/types/layouts/PageTemplates.d.ts +26 -0
  182. package/types/layouts/PageTemplates.d.ts.map +1 -1
  183. package/types/layouts/barTitleSpan.d.ts +29 -0
  184. package/types/layouts/barTitleSpan.d.ts.map +1 -0
  185. package/types/layouts/page.d.ts +6 -1
  186. package/types/layouts/page.d.ts.map +1 -1
  187. package/types/views/ImageGrid.d.ts.map +1 -1
@@ -20,7 +20,7 @@ import { useChartTheme } from "./useChartTheme";
20
20
  export interface BarChartProps
21
21
  extends ChartDatumEvents, Omit<YStackProps, "children" | "width" | "height"> {
22
22
  data: ChartDatum[];
23
- /** Drawing height as a theme size token or pixels (default 200). */
23
+ /** Drawing height as a theme size token, pixels, or "fill" (default 200). */
24
24
  height?: ChartHeight;
25
25
  /** Explicit width bypasses responsive measurement. */
26
26
  width?: number;
@@ -85,7 +85,13 @@ export function BarChart({
85
85
  const interactive = Boolean(onDatumPress || onDatumHoverIn || onDatumHoverOut);
86
86
 
87
87
  return (
88
- <ChartDataFrame data={data} title={title} valueFormatter={valueFormatter} width={width}>
88
+ <ChartDataFrame
89
+ data={data}
90
+ title={title}
91
+ valueFormatter={valueFormatter}
92
+ width={width}
93
+ fill={height === "fill"}
94
+ >
89
95
  <ChartSurface
90
96
  label={ariaLabel}
91
97
  proof={{
@@ -2,6 +2,7 @@ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
2
  import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { afterEach, expect, it, vi } from "vitest";
4
4
  import { XStack, YStack } from "tamagui";
5
+ import { BarChart } from "./BarChart";
5
6
  import { ChartDataView } from "./ChartDataView";
6
7
  import { Sparkline } from "./Sparkline";
7
8
  import { PieChart } from "./PieChart";
@@ -29,7 +30,7 @@ it("opens the complete formatted dataset without expanding the concise chart nam
29
30
  )}
30
31
  </ChartDataView>,
31
32
  );
32
- const image = document.querySelector("svg")!;
33
+ const image = document.querySelector("svg[data-mpo-chart]")!;
33
34
  expect(image.getAttribute("aria-label")).toBe("Revenue trend");
34
35
  expect(image.getAttribute("height")).toBe("36");
35
36
  expect(image.querySelector("button")).toBeNull();
@@ -71,7 +72,9 @@ it("keeps a visible pie legend complete without a dangling data-action associati
71
72
  );
72
73
  expect(screen.getByText("Region 13")).toBeInTheDocument();
73
74
  expect(screen.queryByRole("button", { name: "View data for Revenue" })).toBeNull();
74
- expect(document.querySelector("svg")!.getAttribute("aria-describedby")).toBeNull();
75
+ expect(
76
+ document.querySelector("svg[data-mpo-chart]")!.getAttribute("aria-describedby"),
77
+ ).toBeNull();
75
78
  });
76
79
 
77
80
  it("keeps a consumer metric press override separate from the data action", async () => {
@@ -102,3 +105,21 @@ it("keeps a consumer metric press override separate from the data action", async
102
105
  expect(consumer).toHaveBeenCalledTimes(2);
103
106
  expect(configured).not.toHaveBeenCalled();
104
107
  });
108
+
109
+ it("gives a filled chart's remaining height to its values instead of a data action (LC-215, LC-173)", () => {
110
+ renderWithProviders(
111
+ <YStack height={800}>
112
+ <BarChart
113
+ data={data}
114
+ height="fill"
115
+ title="Revenue"
116
+ valueFormatter={(value) => `USD ${value}`}
117
+ />
118
+ </YStack>,
119
+ );
120
+ const inline = document.querySelector("[data-mpo-chart-data-inline]");
121
+ expect(inline).not.toBeNull();
122
+ expect(inline!.querySelectorAll("[data-mpo-chart-data-row]")).toHaveLength(13);
123
+ expect(screen.getByText("USD 13")).toBeInTheDocument();
124
+ expect(document.querySelector("[data-mpo-chart-data-trigger]")).toBeNull();
125
+ });
@@ -1,4 +1,5 @@
1
1
  import { Button } from "@multiplatform.one/forms";
2
+ import { TableIcon } from "@phosphor-icons/react";
2
3
  import { isWeb } from "@multiplatform.one/platform";
3
4
  import { useResolvedKnobs } from "@multiplatform.one/theme";
4
5
  import { createContext, useContext, useId, useState, type ReactNode } from "react";
@@ -6,6 +7,8 @@ import { Paragraph, XStack, YStack } from "tamagui";
6
7
  import { OverlayHeader } from "../actions/StandardAction";
7
8
  import { Dialog } from "../Dialog";
8
9
  import { ScrollView } from "../ScrollView";
10
+ import { Separator } from "../Separator";
11
+ import { Text } from "../Text";
9
12
  import { sectionHeading } from "../componentColors";
10
13
  import { useTranslation } from "../shared/i18n";
11
14
  import { DialogContent, DialogOverlay } from "../surfaces";
@@ -44,13 +47,13 @@ export function ChartDataView({ data, title, valueFormatter, children }: ChartDa
44
47
  const action = (
45
48
  <Dialog.Trigger asChild>
46
49
  <Button
47
- size="$3"
50
+ circular
51
+ chromeless
52
+ icon={TableIcon}
48
53
  id={triggerId}
49
54
  {...(isWeb ? { "aria-label": actionLabel } : { accessibilityLabel: actionLabel })}
50
55
  {...chartWebProps({ "data-mpo-chart-data-trigger": id })}
51
- >
52
- {t("View data")}
53
- </Button>
56
+ />
54
57
  </Dialog.Trigger>
55
58
  );
56
59
  return (
@@ -128,15 +131,41 @@ export function ChartDataFrame({
128
131
  title,
129
132
  valueFormatter,
130
133
  width,
134
+ fill,
131
135
  children,
132
- }: Omit<ChartDataViewProps, "children"> & { children: ReactNode; width?: number }) {
136
+ }: Omit<ChartDataViewProps, "children"> & {
137
+ children: ReactNode;
138
+ width?: number;
139
+ /** The chart flexes to its region's height (LC-215). */
140
+ fill?: boolean;
141
+ }) {
133
142
  const inherited = useContext(ChartDataContext);
134
143
  const { knobProps } = useResolvedKnobs();
135
144
  if (inherited) return children;
145
+ if (fill)
146
+ return (
147
+ <YStack
148
+ minWidth={0}
149
+ width={width ?? "100%"}
150
+ maxWidth="100%"
151
+ flex={1}
152
+ minHeight={0}
153
+ {...knobProps.gap}
154
+ >
155
+ {children}
156
+ <ChartDataList data={data} title={title} valueFormatter={valueFormatter} />
157
+ </YStack>
158
+ );
136
159
  return (
137
160
  <ChartDataView data={data} title={title} valueFormatter={valueFormatter}>
138
161
  {({ action }) => (
139
- <YStack minWidth={0} width={width ?? "100%"} maxWidth="100%" {...knobProps.gap}>
162
+ <YStack
163
+ minWidth={0}
164
+ width={width ?? "100%"}
165
+ maxWidth="100%"
166
+ {...(fill ? { flex: 1, minHeight: 0 } : null)}
167
+ {...knobProps.gap}
168
+ >
140
169
  {children}
141
170
  <XStack justifyContent="flex-end">{action}</XStack>
142
171
  </YStack>
@@ -145,6 +174,58 @@ export function ChartDataFrame({
145
174
  );
146
175
  }
147
176
 
177
+ /**
178
+ * LC-215: a chart that fills its region stops at its aspect and gives the
179
+ * rest of the height to its own values, one row per datum, so the page never
180
+ * ends in an empty band (LC-173) and the data needs no separate action.
181
+ */
182
+ function ChartDataList({
183
+ data,
184
+ title,
185
+ valueFormatter,
186
+ }: Pick<ChartDataViewProps, "data" | "title" | "valueFormatter">) {
187
+ const { t } = useTranslation();
188
+ const { knobProps } = useResolvedKnobs();
189
+ const format = valueFormatter ?? formatChartValue;
190
+ const rows = data.map((datum, index) =>
191
+ typeof datum === "number" ? { label: String(index + 1), value: datum } : datum,
192
+ );
193
+ const label = title ? `${title}: ${t("data")}` : t("Chart data");
194
+ return (
195
+ <ScrollView
196
+ flex={1}
197
+ minHeight={0}
198
+ {...(isWeb ? { "aria-label": label } : { accessibilityLabel: label })}
199
+ {...chartWebProps({ "data-mpo-chart-data-inline": "true" })}
200
+ >
201
+ {rows.map((datum, index) => (
202
+ <YStack key={index}>
203
+ {index > 0 ? <Separator /> : null}
204
+ <XStack
205
+ minHeight={44}
206
+ alignItems="center"
207
+ {...knobProps.gap}
208
+ {...chartWebProps({ "data-mpo-chart-data-row": String(index) })}
209
+ {...(!isWeb
210
+ ? {
211
+ accessible: true,
212
+ accessibilityLabel: describeDatum({ datum, formatValue: format }),
213
+ }
214
+ : {})}
215
+ >
216
+ <Text flex={1} minWidth={0} {...knobProps.body}>
217
+ {datum.label}
218
+ </Text>
219
+ <Text tabular {...knobProps.body} color={knobProps.textRole.meta.color}>
220
+ {format(datum.value)}
221
+ </Text>
222
+ </XStack>
223
+ </YStack>
224
+ ))}
225
+ </ScrollView>
226
+ );
227
+ }
228
+
148
229
  export function useChartDataViewProps(): Record<string, unknown> {
149
230
  const context = useContext(ChartDataContext);
150
231
  if (!context) return {};
@@ -47,28 +47,37 @@ export function ChartSurface({
47
47
  children,
48
48
  ...stackProps
49
49
  }: ChartSurfaceProps) {
50
- const height = useChartHeight(heightProp, 200);
50
+ const fill = heightProp === "fill";
51
+ const fixedHeight = useChartHeight(heightProp, 200);
51
52
  const dataViewProps = useChartDataViewProps();
52
53
  const { motion, animationEnabled } = useChartTheme();
53
- const [measuredWidth, setMeasuredWidth] = useState(0);
54
- const width = widthProp ?? measuredWidth;
54
+ const [measured, setMeasured] = useState({ width: 0, height: 0 });
55
+ const width = widthProp ?? measured.width;
56
+ const height = fill ? measured.height : fixedHeight;
57
+ // LC-215: a filled chart takes its region's height only up to its aspect,
58
+ // so a dozen bars never stand a phone screen tall or a desktop wide.
59
+ const fillCap = width > 0 ? Math.round(width * (width < 600 ? 0.9 : 0.5)) : undefined;
55
60
 
56
61
  const handleLayout = useCallback((event: LayoutChangeEvent) => {
57
- const next = event.nativeEvent.layout.width;
58
- setMeasuredWidth((prev) => (Math.abs(prev - next) >= 0.5 ? next : prev));
62
+ const { width: nextWidth, height: nextHeight } = event.nativeEvent.layout;
63
+ setMeasured((prev) =>
64
+ Math.abs(prev.width - nextWidth) >= 0.5 || Math.abs(prev.height - nextHeight) >= 0.5
65
+ ? { width: nextWidth, height: nextHeight }
66
+ : prev,
67
+ );
59
68
  }, []);
60
69
 
61
70
  return (
62
71
  <YStack
63
72
  width="100%"
64
- height={height}
73
+ {...(fill ? { flex: 1, minHeight: 0, maxHeight: fillCap } : { height })}
65
74
  overflow="hidden"
66
- onLayout={widthProp === undefined ? handleLayout : undefined}
75
+ onLayout={widthProp === undefined || fill ? handleLayout : undefined}
67
76
  {...transitionProps(motion)}
68
77
  {...(animationEnabled ? { enterStyle: { opacity: 0, y: 6 } } : {})}
69
78
  {...stackProps}
70
79
  >
71
- {width > 0 ? (
80
+ {width > 0 && height > 0 ? (
72
81
  <Svg width={width} height={height} {...chartRootA11yProps(label, proof)} {...dataViewProps}>
73
82
  {children({ width, height })}
74
83
  </Svg>
@@ -1,9 +1,15 @@
1
1
  import { getActiveScaleTokens, usePresetContext, type SizeKey } from "@multiplatform.one/theme";
2
2
 
3
- export type ChartHeight = number | SizeKey;
3
+ /** `"fill"` flexes the chart to the height its region hands it (LC-215). */
4
+ export type ChartHeight = number | SizeKey | "fill";
4
5
 
5
6
  export function useChartHeight(height: ChartHeight | undefined, fallback: number): number {
6
7
  usePresetContext();
7
- const value = typeof height === "string" ? getActiveScaleTokens().size[height] : height;
8
+ const value =
9
+ height === "fill"
10
+ ? undefined
11
+ : typeof height === "string"
12
+ ? getActiveScaleTokens().size[height]
13
+ : height;
8
14
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
9
15
  }
@@ -76,7 +76,8 @@ const X_LABEL_SLOT_GAP = 8;
76
76
  /**
77
77
  * Pick an evenly-strided subset of category labels that fits the plot width
78
78
  * without overlap (LC-10 at phone widths): every label gets a slot sized by
79
- * the widest label (capped), and the stride skips the rest.
79
+ * the widest label (capped), and the stride counts back from the last label
80
+ * so the latest category always keeps its label.
80
81
  */
81
82
  export function thinCategories(labels: string[], plotWidth: number, fontSize: number): string[] {
82
83
  if (labels.length <= 1 || plotWidth <= 0) return labels;
@@ -85,5 +86,5 @@ export function thinCategories(labels: string[], plotWidth: number, fontSize: nu
85
86
  const capacity = Math.max(1, Math.floor(plotWidth / slot));
86
87
  if (labels.length <= capacity) return labels;
87
88
  const stride = Math.ceil(labels.length / capacity);
88
- return labels.filter((_, index) => index % stride === 0);
89
+ return labels.filter((_, index) => (labels.length - 1 - index) % stride === 0);
89
90
  }
@@ -46,6 +46,19 @@ describe("CodeBlock copy (Input.Button + notify)", () => {
46
46
  expect(style.display).not.toBe("none");
47
47
  });
48
48
 
49
+ it("a command is one code surface with its copy inside, top-trailing (LC-267)", () => {
50
+ const result = renderWithProviders(<CodeBlock language="sh">shc node join</CodeBlock>);
51
+ const surface = result.container.querySelector("[data-code-surface]") as HTMLElement | null;
52
+ if (!surface) throw new Error("code surface missing");
53
+ const copy = copyOf(result.container);
54
+ expect(copy && surface.contains(copy)).toBe(true);
55
+ const cell = surface.querySelector("[data-code-copy]") as HTMLElement;
56
+ expect(surface.lastElementChild).toBe(cell);
57
+ expect(getComputedStyle(cell).position).not.toBe("absolute");
58
+ const code = surface.querySelector("[data-register='code']") as HTMLElement;
59
+ expect(code.compareDocumentPosition(cell) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
60
+ });
61
+
49
62
  it("empty code omits the copy cap", () => {
50
63
  const result = renderWithProviders(<CodeBlock>{""}</CodeBlock>);
51
64
  expect(copyOf(result.container)).toBeNull();
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { LinearGradient } from "@tamagui/linear-gradient";
10
- import { ClipboardIcon } from "@phosphor-icons/react";
10
+ import { CopyIcon } from "@phosphor-icons/react";
11
11
  import {
12
12
  MIN_PRESS_TARGET,
13
13
  useTouchSurface,
@@ -30,6 +30,7 @@ import { Code } from "../Code";
30
30
  import { HighlightedCode } from "../HighlightedCode";
31
31
  import { useShikiTokens } from "../Highlighter";
32
32
  import { Pre } from "../Pre";
33
+ import { componentColors } from "../../componentColors";
33
34
 
34
35
  /** Visible lines before the "show more" clamp (content constraint, LC-18). */
35
36
  const VISIBLE_LINES = 22;
@@ -131,7 +132,7 @@ export function CodeBlock({
131
132
  const capSquare = sizeRecipeForToken(String(codeSize), { touch }).height;
132
133
 
133
134
  return (
134
- <YStack position="relative" marginBottom="$4">
135
+ <YStack position="relative">
135
136
  <ErrorBoundary>
136
137
  <YStack
137
138
  ref={clampRef}
@@ -160,9 +161,18 @@ export function CodeBlock({
160
161
  <Spacer size="$4" />
161
162
  </LinearGradient>
162
163
  )}
163
- <Pre {...knobProps.borderRadius} padding={0} marginBottom={0}>
164
+ <Pre
165
+ {...knobProps.borderRadius}
166
+ padding={0}
167
+ marginBottom={0}
168
+ flexDirection="row"
169
+ backgroundColor={componentColors.code.background}
170
+ borderWidth={0}
171
+ data-code-surface=""
172
+ >
164
173
  <ScrollView
165
- style={{ width: "100%" }}
174
+ flex={1}
175
+ minWidth={0}
166
176
  contentContainerStyle={{ minWidth: "100%" }}
167
177
  horizontal
168
178
  showsHorizontalScrollIndicator={false}
@@ -185,33 +195,39 @@ export function CodeBlock({
185
195
  </HighlightedCode>
186
196
  </Code>
187
197
  </ScrollView>
188
- </Pre>
189
- {showCopy && (
190
- <XStack position="absolute" top={0} insetInlineEnd={0} zIndex={zIndex.localTop}>
191
- <Input.Button
192
- glyphRing
193
- size={codeSize}
194
- type="button"
195
- aria-label="Copy to clipboard"
196
- data-press-min={MIN_PRESS_TARGET}
197
- hitSlop={capSquare < MIN_PRESS_TARGET ? pressTargetHitSlop(capSquare) : undefined}
198
- onPress={activateCopy}
199
- onKeyDown={
200
- ((e: KeyboardEvent) => {
201
- if (e.key === "Enter" || e.key === " ") {
202
- e.preventDefault();
203
- e.stopPropagation();
204
- activateCopy();
205
- }
206
- }) as unknown as () => void
207
- }
198
+ {showCopy && (
199
+ <XStack
200
+ flexShrink={0}
201
+ alignSelf={lines > 1 ? "flex-start" : "center"}
202
+ zIndex={zIndex.localTop}
203
+ data-code-copy=""
208
204
  >
209
- <Input.Icon aria-hidden>
210
- <ClipboardIcon />
211
- </Input.Icon>
212
- </Input.Button>
213
- </XStack>
214
- )}
205
+ <Input.Button
206
+ glyphRing
207
+ size={codeSize}
208
+ color={componentColors.text.primary}
209
+ type="button"
210
+ aria-label="Copy to clipboard"
211
+ data-press-min={MIN_PRESS_TARGET}
212
+ hitSlop={capSquare < MIN_PRESS_TARGET ? pressTargetHitSlop(capSquare) : undefined}
213
+ onPress={activateCopy}
214
+ onKeyDown={
215
+ ((e: KeyboardEvent) => {
216
+ if (e.key === "Enter" || e.key === " ") {
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ activateCopy();
220
+ }
221
+ }) as unknown as () => void
222
+ }
223
+ >
224
+ <Input.Icon aria-hidden color={componentColors.text.primary}>
225
+ <CopyIcon weight="bold" />
226
+ </Input.Icon>
227
+ </Input.Button>
228
+ </XStack>
229
+ )}
230
+ </Pre>
215
231
  </YStack>
216
232
  </ErrorBoundary>
217
233
  <NotifyRegion scope="field" region={region} size="$3" />
@@ -0,0 +1,127 @@
1
+ import { useLayoutSizeClass, useResolvedKnobs } from "@multiplatform.one/theme";
2
+ import { useEffect, useRef } from "react";
3
+ import { ScrollView, Text, View, XStack, YStack, useTheme, type YStackProps } from "tamagui";
4
+ import { WarningIcon, XCircleIcon } from "@phosphor-icons/react";
5
+
6
+ export type LogLevel = "debug" | "info" | "warn" | "error";
7
+
8
+ export interface LogEntry {
9
+ /** Stable key; defaults to the entry's index. */
10
+ id?: string;
11
+ /** When it happened: an ISO string or a Date. Shown as HH:MM:SS. */
12
+ time: string | Date;
13
+ level?: LogLevel;
14
+ /** Who wrote it ("worker-2"), a short muted column. */
15
+ source?: string;
16
+ message: string;
17
+ }
18
+
19
+ export interface LogViewProps extends Omit<YStackProps, "children"> {
20
+ entries: readonly LogEntry[];
21
+ /** Names the log for assistive tech ("Sync worker log"). */
22
+ label: string;
23
+ /** Keep the newest entry in view as entries arrive. Default: true. */
24
+ follow?: boolean;
25
+ }
26
+
27
+ const levelInk = { warn: "yellow11", error: "red11" } as const;
28
+ const levelGlyph = { warn: WarningIcon, error: XCircleIcon } as const;
29
+
30
+ function clockTime(time: string | Date): string {
31
+ const iso = typeof time === "string" ? time : time.toISOString();
32
+ return iso.match(/T(\d{2}:\d{2}:\d{2})/)?.[1] ?? iso;
33
+ }
34
+
35
+ /**
36
+ * A log or terminal stream (LC-215): one row per entry, the time and source
37
+ * in fixed muted columns and the message wrapping under itself, so the
38
+ * columns stay aligned at any width. At compact width the time, level and
39
+ * source sit on a line above the message, which then takes the full width.
40
+ * Only a warning or an error carries a mark: a glyph leading its message in
41
+ * the level's ink, never a word and never a dot between two values (LC-261).
42
+ * The log sits on the page ground and fills whatever
43
+ * region it is given; its first glyph sits on the page line.
44
+ */
45
+ export function LogView({ entries, label, follow = true, ...rest }: LogViewProps) {
46
+ const { knobProps } = useResolvedKnobs({ component: "Screen" });
47
+ const compact = useLayoutSizeClass() === "compact";
48
+ const theme = useTheme();
49
+ const scroller = useRef<{ scrollToEnd?: (options?: { animated?: boolean }) => void } | null>(
50
+ null,
51
+ );
52
+ useEffect(() => {
53
+ if (follow) scroller.current?.scrollToEnd?.({ animated: false });
54
+ }, [entries, follow]);
55
+
56
+ return (
57
+ <YStack flex={1} minHeight={0} data-log-view="" role="log" aria-label={label} {...rest}>
58
+ <ScrollView ref={scroller as never} flex={1} keyboardShouldPersistTaps="handled">
59
+ <YStack
60
+ {...knobProps.panelPadding}
61
+ {...(compact ? null : { paddingHorizontal: 0 })}
62
+ gap={compact ? "$3" : "$1"}
63
+ >
64
+ {entries.map((entry, index) => {
65
+ const level = entry.level === "warn" || entry.level === "error" ? entry.level : null;
66
+ const ink = level ? (`$${levelInk[level]}` as const) : "$color12";
67
+ const Glyph = level ? levelGlyph[level] : null;
68
+ const meta = (
69
+ <>
70
+ <Text fontFamily="$mono" fontSize="$3" color="$color10" flexShrink={0}>
71
+ {clockTime(entry.time)}
72
+ </Text>
73
+ {entry.source ? (
74
+ <Text
75
+ fontFamily="$mono"
76
+ fontSize="$3"
77
+ color="$color10"
78
+ flexShrink={0}
79
+ numberOfLines={1}
80
+ >
81
+ {entry.source}
82
+ </Text>
83
+ ) : null}
84
+ </>
85
+ );
86
+ const message = (
87
+ <XStack
88
+ gap="$1.5"
89
+ alignItems="flex-start"
90
+ flex={compact ? undefined : 1}
91
+ minWidth={0}
92
+ >
93
+ {Glyph ? (
94
+ <View height="$1" justifyContent="center" flexShrink={0} aria-hidden>
95
+ <Glyph
96
+ size={14}
97
+ weight="bold"
98
+ color={
99
+ level ? (theme[levelInk[level]]?.get() as string | undefined) : undefined
100
+ }
101
+ />
102
+ </View>
103
+ ) : null}
104
+ <Text fontFamily="$mono" fontSize="$3" color={ink} flex={1} minWidth={0}>
105
+ {entry.message}
106
+ </Text>
107
+ </XStack>
108
+ );
109
+ return compact ? (
110
+ <YStack key={entry.id ?? index} data-log-entry="">
111
+ <XStack gap="$2" alignItems="center">
112
+ {meta}
113
+ </XStack>
114
+ {message}
115
+ </YStack>
116
+ ) : (
117
+ <XStack key={entry.id ?? index} gap="$3" alignItems="flex-start" data-log-entry="">
118
+ {meta}
119
+ {message}
120
+ </XStack>
121
+ );
122
+ })}
123
+ </YStack>
124
+ </ScrollView>
125
+ </YStack>
126
+ );
127
+ }
package/src/code/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./CodeBlock";
3
3
  export * from "./HighlightedCode";
4
4
  export * from "./Highlighter";
5
5
  export * from "./Pre";
6
+ export * from "./LogView";
@@ -0,0 +1,93 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
2
+ import { DownloadSimpleIcon, PauseIcon, type IconProps } from "@phosphor-icons/react";
3
+ import { Button } from "@multiplatform.one/forms";
4
+ import { BarChart } from "../charts/BarChart";
5
+ import { LogView, type LogEntry } from "../code/LogView";
6
+ import { DensePageLayout } from "./PageTemplates";
7
+ import { StatusDot } from "./StatusDot";
8
+
9
+ const PauseFillIcon = (props: IconProps) => <PauseIcon weight="fill" {...props} />;
10
+
11
+ const tables = ["customers", "orders", "invoices", "payments", "shipments", "returns"];
12
+
13
+ const logEntries: LogEntry[] = Array.from({ length: 64 }, (_, index) => {
14
+ const second = 7 + index * 13;
15
+ const time = `2026-10-29T14:${String(Math.floor(second / 60) % 60).padStart(2, "0")}:${String(second % 60).padStart(2, "0")}Z`;
16
+ const table = tables[index % tables.length];
17
+ if (index % 17 === 11)
18
+ return {
19
+ time,
20
+ level: "error",
21
+ source: `worker-${index % 4}`,
22
+ message: `push ${table} failed: 409 conflict on row ${1200 + index}, retrying in 4s`,
23
+ };
24
+ if (index % 9 === 4)
25
+ return {
26
+ time,
27
+ level: "warn",
28
+ source: `worker-${index % 4}`,
29
+ message: `${table} lagging 38s behind the primary`,
30
+ };
31
+ return {
32
+ time,
33
+ level: "info",
34
+ source: `worker-${index % 4}`,
35
+ message: `synced ${table}: ${(index * 37) % 400} rows in ${120 + ((index * 53) % 900)} ms`,
36
+ };
37
+ });
38
+
39
+ const weeks = [42, 57, 49, 63, 71, 66, 80, 74, 88, 92, 85, 97];
40
+ const deploys = weeks.map((value, index) => ({ label: `W${index + 31}`, value }));
41
+
42
+ const meta: Meta<typeof DensePageLayout> = {
43
+ title: "Components/DenseContent",
44
+ component: DensePageLayout,
45
+ parameters: {
46
+ status: { type: "beta" },
47
+ layout: "fullscreen",
48
+ docs: {
49
+ description: {
50
+ component:
51
+ "LC-215 DENSE-CONTENT-BLEEDS: a page whose content is a log, terminal or chart keeps its head (title, live status, icon actions) and gives the rest of the height to the surface. On a phone the surface bleeds to both screen edges; from `medium` up it stays in the page column at the measure its content reads at: the page column for a log, the readable `expanded` measure for a chart.",
52
+ },
53
+ },
54
+ },
55
+ };
56
+ export default meta;
57
+
58
+ type Story = StoryObj<typeof DensePageLayout>;
59
+
60
+ export const Log: Story = {
61
+ render: () => (
62
+ <DensePageLayout
63
+ kind="log"
64
+ title="Sync worker"
65
+ status={<StatusDot tone="positive">Running</StatusDot>}
66
+ actions={
67
+ <>
68
+ <Button circular chromeless icon={PauseFillIcon} aria-label="Pause the stream" />
69
+ <Button circular chromeless icon={DownloadSimpleIcon} aria-label="Download the log" />
70
+ </>
71
+ }
72
+ >
73
+ <LogView label="Sync worker log" entries={logEntries} />
74
+ </DensePageLayout>
75
+ ),
76
+ };
77
+
78
+ export const Chart: Story = {
79
+ render: () => (
80
+ <DensePageLayout
81
+ kind="chart"
82
+ measure="readable"
83
+ gutter
84
+ title="Deploys"
85
+ subtitle="Last 12 weeks"
86
+ actions={
87
+ <Button circular chromeless icon={DownloadSimpleIcon} aria-label="Download the data" />
88
+ }
89
+ >
90
+ <BarChart data={deploys} height="fill" title="Deploys per week" />
91
+ </DensePageLayout>
92
+ ),
93
+ };
@@ -102,5 +102,7 @@ describe("EmptyState state anatomy (LC-171)", () => {
102
102
  expect(command.textContent).toContain("shc tenant create <name>");
103
103
  expect(screen.getByLabelText("Copy to clipboard")).toBeTruthy();
104
104
  expect(document.querySelectorAll("[data-empty-intent] p")).toHaveLength(0);
105
+ const surface = command.querySelector("[data-code-surface]");
106
+ expect(surface?.contains(screen.getByLabelText("Copy to clipboard"))).toBe(true);
105
107
  });
106
108
  });