@kinetixui/ui 0.4.1 → 0.4.3

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/dist/index.d.ts CHANGED
@@ -474,6 +474,28 @@ type ChartConfig = {
474
474
  declare const ChartContainer: React$1.ForwardRefExoticComponent<Omit<React$1.ClassAttributes<HTMLDivElement> & React$1.HTMLAttributes<HTMLDivElement> & {
475
475
  config: ChartConfig;
476
476
  children: React$1.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
477
+ /**
478
+ * Text alternative for the chart (WCAG 1.1.1). A chart conveys its data by
479
+ * colour + position, which a screen reader can't relay — pass a one-line
480
+ * summary of what it shows and the trend. Falls back to "Chart".
481
+ * Cartesian charts should also get `accessibilityLayer` on the Recharts
482
+ * primitive for keyboard data-point navigation.
483
+ */
484
+ label?: string;
485
+ /**
486
+ * Render a placeholder in place of the chart. `"loading"` shows a shimmer,
487
+ * `"empty"` / `"error"` show a message (override with `stateMessage`).
488
+ */
489
+ state?: "loading" | "empty" | "error";
490
+ stateMessage?: string;
491
+ /**
492
+ * Visually-hidden `<table>` rendered after the chart so a screen reader can
493
+ * read the underlying numbers. Pair with a concise `label`.
494
+ */
495
+ srTable?: {
496
+ columns: React$1.ReactNode[];
497
+ rows: React$1.ReactNode[][];
498
+ };
477
499
  }, "ref"> & React$1.RefAttributes<HTMLDivElement>>;
478
500
  declare const ChartStyle: ({ id, config }: {
479
501
  id: string;
@@ -1120,6 +1142,9 @@ declare const Metric: React$1.ForwardRefExoticComponent<MetricProps & React$1.Re
1120
1142
 
1121
1143
  /**
1122
1144
  * NumberInput — a numeric field with increment / decrement controls.
1145
+ * The wrapper shows `--shadow-focus` on `focus-within`; each stepper also
1146
+ * draws its own `--ring` inset outline on `:focus-visible` so keyboard users
1147
+ * can tell which control is focused.
1123
1148
  */
1124
1149
  interface NumberInputProps extends Omit<React$1.InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange"> {
1125
1150
  value?: number;
package/dist/index.js CHANGED
@@ -1545,14 +1545,50 @@ function useChart() {
1545
1545
  if (!context) throw new Error("useChart must be used within a <ChartContainer />");
1546
1546
  return context;
1547
1547
  }
1548
- var ChartContainer = React32.forwardRef(({ id, className, children, config, ...props }, ref) => {
1548
+ var ChartContainer = React32.forwardRef(({
1549
+ id,
1550
+ className,
1551
+ children,
1552
+ config,
1553
+ label,
1554
+ state,
1555
+ stateMessage,
1556
+ srTable,
1557
+ role,
1558
+ "aria-label": ariaLabel,
1559
+ ...props
1560
+ }, ref) => {
1549
1561
  const uniqueId = React32.useId();
1550
1562
  const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
1563
+ const name = ariaLabel ?? label ?? "Chart";
1564
+ const innerRef = React32.useRef(null);
1565
+ const setRef = React32.useCallback(
1566
+ (node) => {
1567
+ innerRef.current = node;
1568
+ if (typeof ref === "function") ref(node);
1569
+ else if (ref) ref.current = node;
1570
+ },
1571
+ [ref]
1572
+ );
1573
+ React32.useEffect(() => {
1574
+ const el = innerRef.current;
1575
+ if (!el) return;
1576
+ const scrub = () => {
1577
+ el.querySelector(".recharts-surface")?.setAttribute("aria-label", name);
1578
+ el.querySelectorAll('[role="img"]:not([data-chart])').forEach((n) => n.removeAttribute("role"));
1579
+ };
1580
+ scrub();
1581
+ const mo = new MutationObserver(scrub);
1582
+ mo.observe(el, { childList: true, subtree: true });
1583
+ return () => mo.disconnect();
1584
+ }, [name]);
1551
1585
  return /* @__PURE__ */ jsx37(ChartContext.Provider, { value: { config }, children: /* @__PURE__ */ jsxs15(
1552
1586
  "div",
1553
1587
  {
1554
1588
  "data-chart": chartId,
1555
- ref,
1589
+ ref: setRef,
1590
+ role: role ?? "img",
1591
+ "aria-label": name,
1556
1592
  className: cn(
1557
1593
  "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
1558
1594
  className
@@ -1560,12 +1596,53 @@ var ChartContainer = React32.forwardRef(({ id, className, children, config, ...p
1560
1596
  ...props,
1561
1597
  children: [
1562
1598
  /* @__PURE__ */ jsx37(ChartStyle, { id: chartId, config }),
1563
- /* @__PURE__ */ jsx37(RechartsPrimitive.ResponsiveContainer, { children })
1599
+ state ? /* @__PURE__ */ jsx37(ChartState, { state, message: stateMessage }) : /* @__PURE__ */ jsx37(RechartsPrimitive.ResponsiveContainer, { children }),
1600
+ srTable && !state ? /* @__PURE__ */ jsx37(ChartSrTable, { ...srTable }) : null
1564
1601
  ]
1565
1602
  }
1566
1603
  ) });
1567
1604
  });
1568
1605
  ChartContainer.displayName = "Chart";
1606
+ var STATE_COPY = {
1607
+ loading: "Loading chart\u2026",
1608
+ empty: "No data to show",
1609
+ error: "Couldn't load this chart"
1610
+ };
1611
+ function ChartState({
1612
+ state,
1613
+ message
1614
+ }) {
1615
+ return /* @__PURE__ */ jsxs15(
1616
+ "div",
1617
+ {
1618
+ role: state === "error" ? "alert" : "status",
1619
+ className: cn(
1620
+ "flex h-full w-full flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-center text-xs",
1621
+ state === "error" ? "border-destructive/40 text-destructive" : "border-border text-muted-foreground"
1622
+ ),
1623
+ children: [
1624
+ state === "loading" ? /* @__PURE__ */ jsx37("div", { "aria-hidden": true, className: "flex w-full max-w-[220px] items-end justify-between gap-1.5", children: [40, 72, 56, 88, 48, 64, 80].map((h, i) => /* @__PURE__ */ jsx37(
1625
+ "span",
1626
+ {
1627
+ className: "w-full animate-pulse rounded-sm bg-muted",
1628
+ style: { height: `${h}px`, animationDelay: `${i * 90}ms` }
1629
+ },
1630
+ i
1631
+ )) }) : /* @__PURE__ */ jsx37("svg", { "aria-hidden": true, viewBox: "0 0 24 24", className: "size-5", fill: "none", stroke: "currentColor", strokeWidth: 1.5, children: state === "empty" ? /* @__PURE__ */ jsx37("path", { d: "M3 3v18h18M7 15l4-4 3 3 5-6", strokeLinecap: "round", strokeLinejoin: "round" }) : /* @__PURE__ */ jsx37("path", { d: "M12 9v4m0 4h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.42 0Z", strokeLinecap: "round", strokeLinejoin: "round" }) }),
1632
+ /* @__PURE__ */ jsx37("span", { children: message ?? STATE_COPY[state] })
1633
+ ]
1634
+ }
1635
+ );
1636
+ }
1637
+ function ChartSrTable({
1638
+ columns,
1639
+ rows
1640
+ }) {
1641
+ return /* @__PURE__ */ jsxs15("table", { className: "sr-only", children: [
1642
+ /* @__PURE__ */ jsx37("thead", { children: /* @__PURE__ */ jsx37("tr", { children: columns.map((c, i) => /* @__PURE__ */ jsx37("th", { scope: "col", children: c }, i)) }) }),
1643
+ /* @__PURE__ */ jsx37("tbody", { children: rows.map((r, i) => /* @__PURE__ */ jsx37("tr", { children: r.map((cell, j) => /* @__PURE__ */ jsx37("td", { children: cell }, j)) }, i)) })
1644
+ ] });
1645
+ }
1569
1646
  var ChartStyle = ({ id, config }) => {
1570
1647
  const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
1571
1648
  if (!colorConfig.length) return null;
@@ -3530,7 +3607,7 @@ var NumberInput = React60.forwardRef(
3530
3607
  "aria-label": "Decrease",
3531
3608
  disabled: disabled || min != null && current <= min,
3532
3609
  onClick: () => set(current - step),
3533
- className: "flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
3610
+ className: "flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
3534
3611
  children: /* @__PURE__ */ jsx66(Minus3, { className: "size-4" })
3535
3612
  }
3536
3613
  ),
@@ -3560,7 +3637,7 @@ var NumberInput = React60.forwardRef(
3560
3637
  "aria-label": "Increase",
3561
3638
  disabled: disabled || max != null && current >= max,
3562
3639
  onClick: () => set(current + step),
3563
- className: "flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
3640
+ className: "flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
3564
3641
  children: /* @__PURE__ */ jsx66(Plus, { className: "size-4" })
3565
3642
  }
3566
3643
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinetixui/ui",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "description": "KinetixUI React components — CVA + Radix + Tailwind on the KinetixUI token contract. Also distributed via the kinetixui CLI as a component registry.",
6
6
  "keywords": [
@@ -89,7 +89,7 @@
89
89
  "react-dom": ">=18"
90
90
  },
91
91
  "devDependencies": {
92
- "@kinetixui/tokens": "0.4.1",
92
+ "@kinetixui/tokens": "0.4.3",
93
93
  "@testing-library/jest-dom": "^6.6.3",
94
94
  "@testing-library/react": "^16.1.0",
95
95
  "@testing-library/user-event": "^14.5.2",
@@ -27,16 +27,78 @@ const ChartContainer = React.forwardRef<
27
27
  React.ComponentProps<"div"> & {
28
28
  config: ChartConfig;
29
29
  children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
30
+ /**
31
+ * Text alternative for the chart (WCAG 1.1.1). A chart conveys its data by
32
+ * colour + position, which a screen reader can't relay — pass a one-line
33
+ * summary of what it shows and the trend. Falls back to "Chart".
34
+ * Cartesian charts should also get `accessibilityLayer` on the Recharts
35
+ * primitive for keyboard data-point navigation.
36
+ */
37
+ label?: string;
38
+ /**
39
+ * Render a placeholder in place of the chart. `"loading"` shows a shimmer,
40
+ * `"empty"` / `"error"` show a message (override with `stateMessage`).
41
+ */
42
+ state?: "loading" | "empty" | "error";
43
+ stateMessage?: string;
44
+ /**
45
+ * Visually-hidden `<table>` rendered after the chart so a screen reader can
46
+ * read the underlying numbers. Pair with a concise `label`.
47
+ */
48
+ srTable?: { columns: React.ReactNode[]; rows: React.ReactNode[][] };
30
49
  }
31
- >(({ id, className, children, config, ...props }, ref) => {
50
+ >(({
51
+ id,
52
+ className,
53
+ children,
54
+ config,
55
+ label,
56
+ state,
57
+ stateMessage,
58
+ srTable,
59
+ role,
60
+ "aria-label": ariaLabel,
61
+ ...props
62
+ }, ref) => {
32
63
  const uniqueId = React.useId();
33
64
  const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
65
+ const name = ariaLabel ?? label ?? "Chart";
66
+
67
+ const innerRef = React.useRef<HTMLDivElement | null>(null);
68
+ const setRef = React.useCallback(
69
+ (node: HTMLDivElement | null) => {
70
+ innerRef.current = node;
71
+ if (typeof ref === "function") ref(node);
72
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
73
+ },
74
+ [ref],
75
+ );
76
+
77
+ // The container carries the text alternative (role="img" + aria-label).
78
+ // Recharts additionally (a) tags individual sector / dot <path>s with
79
+ // role="img" and no <title> — redundant noise that fails axe's svg-img-alt —
80
+ // and (b) leaves its accessibilityLayer <svg role="application"> unnamed.
81
+ // Fix both on the rendered output; re-run on Recharts re-layout.
82
+ React.useEffect(() => {
83
+ const el = innerRef.current;
84
+ if (!el) return;
85
+ const scrub = () => {
86
+ el.querySelector(".recharts-surface")?.setAttribute("aria-label", name);
87
+ el.querySelectorAll('[role="img"]:not([data-chart])').forEach((n) => n.removeAttribute("role"));
88
+ };
89
+ scrub();
90
+ const mo = new MutationObserver(scrub);
91
+ mo.observe(el, { childList: true, subtree: true });
92
+ return () => mo.disconnect();
93
+ }, [name]);
34
94
 
35
95
  return (
36
96
  <ChartContext.Provider value={{ config }}>
37
97
  <div
38
98
  data-chart={chartId}
39
- ref={ref}
99
+ ref={setRef}
100
+ role={role ?? "img"}
101
+ aria-label={name}
40
102
  className={cn(
41
103
  "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
42
104
  className,
@@ -44,13 +106,94 @@ const ChartContainer = React.forwardRef<
44
106
  {...props}
45
107
  >
46
108
  <ChartStyle id={chartId} config={config} />
47
- <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
109
+ {state ? (
110
+ <ChartState state={state} message={stateMessage} />
111
+ ) : (
112
+ <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
113
+ )}
114
+ {srTable && !state ? <ChartSrTable {...srTable} /> : null}
48
115
  </div>
49
116
  </ChartContext.Provider>
50
117
  );
51
118
  });
52
119
  ChartContainer.displayName = "Chart";
53
120
 
121
+ const STATE_COPY = {
122
+ loading: "Loading chart…",
123
+ empty: "No data to show",
124
+ error: "Couldn't load this chart",
125
+ } as const;
126
+
127
+ function ChartState({
128
+ state,
129
+ message,
130
+ }: {
131
+ state: "loading" | "empty" | "error";
132
+ message?: string;
133
+ }) {
134
+ return (
135
+ <div
136
+ role={state === "error" ? "alert" : "status"}
137
+ className={cn(
138
+ "flex h-full w-full flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-center text-xs",
139
+ state === "error" ? "border-destructive/40 text-destructive" : "border-border text-muted-foreground",
140
+ )}
141
+ >
142
+ {state === "loading" ? (
143
+ <div aria-hidden className="flex w-full max-w-[220px] items-end justify-between gap-1.5">
144
+ {[40, 72, 56, 88, 48, 64, 80].map((h, i) => (
145
+ <span
146
+ key={i}
147
+ className="w-full animate-pulse rounded-sm bg-muted"
148
+ style={{ height: `${h}px`, animationDelay: `${i * 90}ms` }}
149
+ />
150
+ ))}
151
+ </div>
152
+ ) : (
153
+ <svg aria-hidden viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth={1.5}>
154
+ {state === "empty" ? (
155
+ <path d="M3 3v18h18M7 15l4-4 3 3 5-6" strokeLinecap="round" strokeLinejoin="round" />
156
+ ) : (
157
+ <path d="M12 9v4m0 4h.01M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.7 3.86a2 2 0 0 0-3.42 0Z" strokeLinecap="round" strokeLinejoin="round" />
158
+ )}
159
+ </svg>
160
+ )}
161
+ <span>{message ?? STATE_COPY[state]}</span>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ function ChartSrTable({
167
+ columns,
168
+ rows,
169
+ }: {
170
+ columns: React.ReactNode[];
171
+ rows: React.ReactNode[][];
172
+ }) {
173
+ return (
174
+ <table className="sr-only">
175
+ <thead>
176
+ <tr>
177
+ {columns.map((c, i) => (
178
+ <th key={i} scope="col">
179
+ {c}
180
+ </th>
181
+ ))}
182
+ </tr>
183
+ </thead>
184
+ <tbody>
185
+ {rows.map((r, i) => (
186
+ <tr key={i}>
187
+ {r.map((cell, j) => (
188
+ <td key={j}>{cell}</td>
189
+ ))}
190
+ </tr>
191
+ ))}
192
+ </tbody>
193
+ </table>
194
+ );
195
+ }
196
+
54
197
  const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
55
198
  const colorConfig = Object.entries(config).filter(([, c]) => c.theme || c.color);
56
199
  if (!colorConfig.length) return null;
@@ -6,6 +6,9 @@ import { cn } from "../lib/utils";
6
6
 
7
7
  /**
8
8
  * NumberInput — a numeric field with increment / decrement controls.
9
+ * The wrapper shows `--shadow-focus` on `focus-within`; each stepper also
10
+ * draws its own `--ring` inset outline on `:focus-visible` so keyboard users
11
+ * can tell which control is focused.
9
12
  */
10
13
  export interface NumberInputProps
11
14
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange"> {
@@ -48,7 +51,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
48
51
  aria-label="Decrease"
49
52
  disabled={disabled || (min != null && current <= min)}
50
53
  onClick={() => set(current - step)}
51
- className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
54
+ className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
52
55
  >
53
56
  <Minus className="size-4" />
54
57
  </button>
@@ -73,7 +76,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
73
76
  aria-label="Increase"
74
77
  disabled={disabled || (max != null && current >= max)}
75
78
  onClick={() => set(current + step)}
76
- className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
79
+ className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
77
80
  >
78
81
  <Plus className="size-4" />
79
82
  </button>
@@ -70,6 +70,9 @@ export default {
70
70
  3: c("--chart-3"),
71
71
  4: c("--chart-4"),
72
72
  5: c("--chart-5"),
73
+ 6: c("--chart-6"),
74
+ 7: c("--chart-7"),
75
+ 8: c("--chart-8"),
73
76
  },
74
77
  sidebar: {
75
78
  DEFAULT: c("--sidebar"),