@godxjp/ui 16.10.0 → 17.0.1

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 (62) hide show
  1. package/README.md +30 -5
  2. package/dist/components/charts/chart-cartesian.js +1 -6
  3. package/dist/components/charts/pie-chart.js +1 -8
  4. package/dist/components/data-display/empty-state.d.ts +1 -1
  5. package/dist/components/data-display/empty-state.js +25 -7
  6. package/dist/components/data-entry/form-field.js +13 -4
  7. package/dist/components/data-entry/search-select.d.ts +1 -1
  8. package/dist/components/data-entry/search-select.js +97 -19
  9. package/dist/components/data-entry/select.js +15 -4
  10. package/dist/components/feedback/alert.d.ts +11 -2
  11. package/dist/components/feedback/alert.js +51 -9
  12. package/dist/components/general/button.js +2 -0
  13. package/dist/components/general/index.d.ts +2 -0
  14. package/dist/components/general/index.js +2 -0
  15. package/dist/components/general/reveal.d.ts +14 -0
  16. package/dist/components/general/reveal.js +21 -0
  17. package/dist/components/layout/auth-shell.d.ts +11 -0
  18. package/dist/components/layout/auth-shell.js +15 -0
  19. package/dist/components/layout/flex.js +1 -1
  20. package/dist/components/layout/index.d.ts +2 -0
  21. package/dist/components/layout/index.js +2 -0
  22. package/dist/components/layout/page-container.js +4 -4
  23. package/dist/components/navigation/app-setting-picker.js +19 -4
  24. package/dist/components/navigation/pagination.d.ts +1 -1
  25. package/dist/components/navigation/pagination.js +2 -1
  26. package/dist/components/query/data-state.d.ts +1 -1
  27. package/dist/components/query/data-state.js +27 -4
  28. package/dist/components/query/index.d.ts +2 -0
  29. package/dist/components/query/index.js +4 -1
  30. package/dist/i18n/messages/en.json +30 -2
  31. package/dist/i18n/messages/ja.json +30 -2
  32. package/dist/i18n/messages/vi.json +30 -2
  33. package/dist/lib/query-error.d.ts +17 -0
  34. package/dist/lib/query-error.js +58 -0
  35. package/dist/props/components/app.prop.d.ts +9 -1
  36. package/dist/props/components/data-display.prop.d.ts +15 -0
  37. package/dist/props/components/data-entry.prop.d.ts +8 -0
  38. package/dist/props/components/feedback.prop.d.ts +6 -0
  39. package/dist/props/components/general.prop.d.ts +23 -1
  40. package/dist/props/components/layout.prop.d.ts +17 -0
  41. package/dist/props/components/query.prop.d.ts +7 -1
  42. package/dist/props/registry.d.ts +31 -2
  43. package/dist/props/registry.js +44 -2
  44. package/dist/props/vocabulary/index.d.ts +1 -1
  45. package/dist/props/vocabulary/interaction.prop.d.ts +17 -1
  46. package/dist/styles/alert-layout.css +1 -2
  47. package/dist/styles/base.css +1 -1
  48. package/dist/styles/card-layout.css +6 -6
  49. package/dist/styles/index.css +1 -0
  50. package/dist/styles/layout.css +28 -0
  51. package/dist/styles/motion.css +52 -0
  52. package/dist/styles/shell-layout.css +42 -0
  53. package/dist/styles/text-layout.css +1 -1
  54. package/dist/theme/famgia.service.css +15 -14
  55. package/dist/tokens/components/card.css +1 -1
  56. package/dist/tokens/components/feedback.css +4 -0
  57. package/dist/tokens/components/shell.css +10 -0
  58. package/dist/tokens/foundation.css +11 -5
  59. package/package.json +7 -3
  60. package/scripts/ui-audit.mjs +2 -2
  61. package/scripts/visual-audit-rules.mjs +2 -2
  62. package/scripts/visual-audit.mjs +293 -126
package/README.md CHANGED
@@ -139,9 +139,9 @@ bundled fonts). To ship only what you render, import the foundation plus the
139
139
  per-layer files you need (mirrors the JS subpaths — the CSS tree-shakes too):
140
140
 
141
141
  ```css
142
- @import "@godxjp/ui/styles/base"; /* required: tokens + tailwind + base layer */
143
- @import "@godxjp/ui/styles/control"; /* Button, Input, Select, Textarea, toggles */
144
- @import "@godxjp/ui/styles/form-layout"; /* FormField */
142
+ @import "@godxjp/ui/styles/base"; /* required: tokens + tailwind + base layer */
143
+ @import "@godxjp/ui/styles/control"; /* Button, Input, Select, Textarea, toggles */
144
+ @import "@godxjp/ui/styles/form-layout"; /* FormField */
145
145
  @import "@godxjp/ui/styles/dialog-layout"; /* Dialog */
146
146
  /* …only the layers you use. Layer files need `base` first (they use @layer/@apply). */
147
147
  ```
@@ -158,8 +158,8 @@ one face everywhere, or per-language (no `[lang]` selectors to write):
158
158
  ```css
159
159
  :root {
160
160
  --font-sans-base: var(--my-latin), system-ui, sans-serif; /* default face */
161
- --font-sans-ja: "Noto Sans JP", var(--font-sans-base); /* lang="ja" */
162
- --font-sans-vi: "Montserrat", var(--font-sans-base); /* lang="vi" */
161
+ --font-sans-ja: "Noto Sans JP", var(--font-sans-base); /* lang="ja" */
162
+ --font-sans-vi: "Montserrat", var(--font-sans-base); /* lang="vi" */
163
163
  /* also: --font-sans-ko, --font-sans-zh-hans, --font-sans-zh-hant */
164
164
  }
165
165
  ```
@@ -221,6 +221,31 @@ pnpm release --ui <patch|minor|major> --mcp <…|skip> # publish lib + MCP in
221
221
  | `check:mcp-orphans` | every public component HAS a catalog entry (catalog can't rot) |
222
222
  | `check:core-isolation` | the root export pulls no foreign runtime (adapters stay on subpaths) |
223
223
 
224
+ ### Runtime visual audit (Playwright + axe-core)
225
+
226
+ `scripts/visual-audit.mjs` drives a **real browser** over a running app and runs axe-core plus
227
+ computed-style heuristics (target size, OKLCH accent chroma, rendered emoji, mis-laid-out alerts) —
228
+ catching what the static `pnpm audit` (source regexes) can't see. Playwright + `@axe-core/playwright`
229
+ are **optional peers**, installed only by apps that run the audit.
230
+
231
+ ```bash
232
+ # from a consumer, against its running dev/preview server:
233
+ node node_modules/@godxjp/ui/scripts/visual-audit.mjs http://localhost:5173 /invoices /settings
234
+ node node_modules/@godxjp/ui/scripts/visual-audit.mjs http://localhost:5173 --format json # machine-readable
235
+ node node_modules/@godxjp/ui/scripts/visual-audit.mjs --strict http://localhost:5173 # CI gate
236
+ ```
237
+
238
+ **Tested peer range** (pin one of these): `playwright >=1.55 <2` (tested 1.61.1) ·
239
+ `@axe-core/playwright >=4.10 <5` (tested 4.12.1) · `axe-core >=4.10 <5` (tested 4.12.1). Playwright
240
+ 1.55+ is required for the `browser.newContext()` → `context.newPage()` flow axe expects; older
241
+ `browser.newPage()` throws _"Please use browser.newContext()"_.
242
+
243
+ `--format json` **always** emits valid JSON — even on bootstrap failure — with a `status`
244
+ (`ok` · `partial` · `error`) that separates **infrastructure errors** (missing peers, page won't
245
+ load, axe won't inject → `errors[]`, `summary: null`/flagged) from **product findings** (`findings[]`).
246
+ A tool failure can therefore never be misread as "zero violations". `pnpm check:visual-audit` is the
247
+ CI smoke test: it serves a fixture page tripping all five rule families and asserts each one fires.
248
+
224
249
  This repo ships two packages — `@godxjp/ui` (this dir) and `@godxjp/ui-mcp` (`mcp/`). They keep
225
250
  separate version lines but release together via `pnpm release`; see DEVELOPMENT.md §6.
226
251
  The **preview app auto-deploys to [GitHub Pages](https://godx-jp.github.io/godxjp-ui/)** on every push to `main`
@@ -17,12 +17,7 @@ import {
17
17
  } from "recharts";
18
18
  import { EmptyState } from "../data-display/empty-state.js";
19
19
  import { useTranslation } from "../../i18n/use-translation.js";
20
- import {
21
- ChartFrame,
22
- chartColor,
23
- chartHeight,
24
- useChartNumberFormat
25
- } from "./chart-frame.js";
20
+ import { ChartFrame, chartColor, chartHeight, useChartNumberFormat } from "./chart-frame.js";
26
21
  import { buildCartesianSummary } from "./chart-summary.js";
27
22
  function CartesianChart({
28
23
  kind,
@@ -1,13 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import {
4
- Cell,
5
- Legend,
6
- Pie,
7
- PieChart as RPieChart,
8
- ResponsiveContainer,
9
- Tooltip
10
- } from "recharts";
3
+ import { Cell, Legend, Pie, PieChart as RPieChart, ResponsiveContainer, Tooltip } from "recharts";
11
4
  import { EmptyState } from "../data-display/empty-state.js";
12
5
  import { useTranslation } from "../../i18n/use-translation.js";
13
6
  import { ChartFrame, chartColor, chartHeight, useChartNumberFormat } from "./chart-frame.js";
@@ -1,3 +1,3 @@
1
1
  import type { EmptyStateProp } from "../../props/components/data-display.prop.js";
2
2
  export type { EmptyStateProp, EmptyStateProp as EmptyStateProps, } from "../../props/components/data-display.prop.js";
3
- export declare function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProp): import("react/jsx-runtime").JSX.Element;
3
+ export declare function EmptyState({ icon: Icon, title, description, action, variant, tone, className, }: EmptyStateProp): import("react/jsx-runtime").JSX.Element;
@@ -1,12 +1,30 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "../../lib/utils.js";
3
- function EmptyState({ icon: Icon, title, description, action, className }) {
4
- return /* @__PURE__ */ jsxs("div", { "data-slot": "empty-state", role: "status", className: cn("ui-empty-state", className), children: [
5
- Icon && /* @__PURE__ */ jsx("div", { className: "ui-empty-state-icon", children: /* @__PURE__ */ jsx(Icon, { className: "text-muted-foreground size-6", "aria-hidden": "true" }) }),
6
- /* @__PURE__ */ jsx("h3", { className: "ui-empty-state-title", children: title }),
7
- description && /* @__PURE__ */ jsx("p", { className: "ui-empty-state-description", children: description }),
8
- action && /* @__PURE__ */ jsx("div", { children: action })
9
- ] });
3
+ function EmptyState({
4
+ icon: Icon,
5
+ title,
6
+ description,
7
+ action,
8
+ variant = "page",
9
+ tone = "muted",
10
+ className
11
+ }) {
12
+ return /* @__PURE__ */ jsxs(
13
+ "div",
14
+ {
15
+ "data-slot": "empty-state",
16
+ "data-variant": variant,
17
+ "data-tone": tone,
18
+ role: "status",
19
+ className: cn("ui-empty-state", className),
20
+ children: [
21
+ Icon && variant !== "compact" && /* @__PURE__ */ jsx("div", { className: "ui-empty-state-icon", children: /* @__PURE__ */ jsx(Icon, { className: "text-muted-foreground size-6", "aria-hidden": "true" }) }),
22
+ /* @__PURE__ */ jsx("h3", { className: "ui-empty-state-title", children: title }),
23
+ description && /* @__PURE__ */ jsx("p", { className: "ui-empty-state-description", children: description }),
24
+ action && /* @__PURE__ */ jsx("div", { children: action })
25
+ ]
26
+ }
27
+ );
10
28
  }
11
29
  export {
12
30
  EmptyState
@@ -37,6 +37,9 @@ function FormField({
37
37
  );
38
38
  }
39
39
  const childProps = React.isValidElement(children) ? children.props : void 0;
40
+ const mergeIds = (...values) => Array.from(new Set(values.flatMap((value) => value?.split(/\s+/).filter(Boolean) ?? []))).join(
41
+ " "
42
+ ) || void 0;
40
43
  const childWithA11y = React.isValidElement(children) ? React.cloneElement(children, {
41
44
  // The label is associated via aria-labelledby (not <label for>): composite
42
45
  // controls (Radio.Group, checkbox lists, range pairs) have no labelable root,
@@ -45,10 +48,16 @@ function FormField({
45
48
  "aria-labelledby": childProps?.["aria-labelledby"] ?? labelId,
46
49
  // Helper and error can coexist: helper stays on aria-describedby, the error on
47
50
  // aria-errormessage (surfaced when aria-invalid is true).
48
- "aria-describedby": helperId,
49
- "aria-errormessage": errorId,
50
- "aria-required": required ? true : void 0,
51
- "aria-invalid": !!error || void 0
51
+ "aria-describedby": mergeIds(
52
+ childProps?.["aria-describedby"],
53
+ helperId
54
+ ),
55
+ "aria-errormessage": mergeIds(
56
+ childProps?.["aria-errormessage"],
57
+ errorId
58
+ ),
59
+ "aria-required": required ? true : childProps?.["aria-required"],
60
+ "aria-invalid": error ? true : childProps?.["aria-invalid"]
52
61
  }) : children;
53
62
  const style = {};
54
63
  if (labelWidth != null)
@@ -8,4 +8,4 @@ export type { SearchSelectProp, SearchSelectProp as SearchSelectProps, SearchSel
8
8
  * Custom per-option rendering via `renderOption` (Ant-Design style). Form-submittable via
9
9
  * `name`; e2e-testable by the trigger's `data-testid` + each option's `${data-testid}-option-${value}`.
10
10
  */
11
- export declare function SearchSelect({ value: valueProp, defaultValue, onValueChange, options: staticOptions, loadOptions, renderOption, labelRender, selectedLabel, selectedIcon, placeholder, searchPlaceholder, emptyMessage, loadingMessage, clearLabel, clearable, disabled, name, id, className, "data-testid": dataTestId, }: SearchSelectProp): import("react/jsx-runtime").JSX.Element;
11
+ export declare function SearchSelect({ value: valueProp, defaultValue, onValueChange, options: staticOptions, loadOptions, renderOption, labelRender, selectedLabel, selectedIcon, placeholder, searchPlaceholder, emptyMessage, loadingMessage, errorMessage, clearLabel, clearable, disabled, name, id, className, "data-testid": dataTestId, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, "aria-describedby": ariaDescribedby, "aria-errormessage": ariaErrorMessage, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, }: SearchSelectProp): import("react/jsx-runtime").JSX.Element;
@@ -7,7 +7,7 @@ import { cn } from "../../lib/utils.js";
7
7
  import { controlOpenRingClass } from "../../lib/control-styles.js";
8
8
  import { Button } from "../general/button.js";
9
9
  import { Popover, PopoverContent, PopoverTrigger } from "../data-display/popover.js";
10
- import { Command, CommandGroup, CommandItem, CommandList } from "./command.js";
10
+ import { Command, CommandGroup } from "./command.js";
11
11
  import { Input } from "./input.js";
12
12
  const DEBOUNCE_MS = 250;
13
13
  function SearchSelect({
@@ -24,13 +24,20 @@ function SearchSelect({
24
24
  searchPlaceholder,
25
25
  emptyMessage,
26
26
  loadingMessage,
27
+ errorMessage,
27
28
  clearLabel,
28
29
  clearable = true,
29
30
  disabled = false,
30
31
  name,
31
32
  id,
32
33
  className,
33
- "data-testid": dataTestId
34
+ "data-testid": dataTestId,
35
+ "aria-label": ariaLabel,
36
+ "aria-labelledby": ariaLabelledby,
37
+ "aria-describedby": ariaDescribedby,
38
+ "aria-errormessage": ariaErrorMessage,
39
+ "aria-invalid": ariaInvalid,
40
+ "aria-required": ariaRequired
34
41
  }) {
35
42
  const { t } = useTranslation();
36
43
  const reactId = React.useId();
@@ -43,6 +50,7 @@ function SearchSelect({
43
50
  const [page, setPage] = React.useState(1);
44
51
  const [hasMore, setHasMore] = React.useState(false);
45
52
  const [loading, setLoading] = React.useState(false);
53
+ const [error, setError] = React.useState(false);
46
54
  const [activeIndex, setActiveIndex] = React.useState(0);
47
55
  const [picked, setPicked] = React.useState(null);
48
56
  const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
@@ -70,12 +78,22 @@ function SearchSelect({
70
78
  async (nextPage, search, append) => {
71
79
  const ticket = ++reqId.current;
72
80
  setLoading(true);
81
+ if (!append) setError(false);
73
82
  try {
74
83
  const result = await resolvedLoad({ query: search, page: nextPage });
75
84
  if (ticket !== reqId.current) return;
76
85
  setLoaded((prev) => append ? [...prev, ...result.options] : result.options);
86
+ if (!append) {
87
+ const firstEnabled = result.options.findIndex((option) => !option.disabled);
88
+ setActiveIndex(firstEnabled >= 0 ? firstEnabled : 0);
89
+ }
77
90
  setHasMore(Boolean(result.hasMore));
78
91
  setPage(nextPage);
92
+ } catch {
93
+ if (ticket !== reqId.current) return;
94
+ if (!append) setLoaded([]);
95
+ setHasMore(false);
96
+ setError(true);
79
97
  } finally {
80
98
  if (ticket === reqId.current) setLoading(false);
81
99
  }
@@ -131,13 +149,36 @@ function SearchSelect({
131
149
  void fetchPage(page + 1, debouncedQuery, true);
132
150
  }
133
151
  };
152
+ const moveActive = (direction) => {
153
+ if (flatOrdered.length === 0) return;
154
+ let next = activeIndex;
155
+ for (let checked = 0; checked < flatOrdered.length; checked += 1) {
156
+ next = (next + direction + flatOrdered.length) % flatOrdered.length;
157
+ if (!flatOrdered[next]?.disabled) {
158
+ setActiveIndex(next);
159
+ return;
160
+ }
161
+ }
162
+ };
163
+ const moveToEdge = (edge) => {
164
+ const indices = flatOrdered.map((_, index) => index);
165
+ if (edge === "end") indices.reverse();
166
+ const next = indices.find((index) => !flatOrdered[index]?.disabled);
167
+ if (next !== void 0) setActiveIndex(next);
168
+ };
134
169
  const onKeyDown = (event) => {
135
170
  if (event.key === "ArrowDown") {
136
171
  event.preventDefault();
137
- setActiveIndex((i) => Math.min(i + 1, Math.max(flatOrdered.length - 1, 0)));
172
+ moveActive(1);
138
173
  } else if (event.key === "ArrowUp") {
139
174
  event.preventDefault();
140
- setActiveIndex((i) => Math.max(i - 1, 0));
175
+ moveActive(-1);
176
+ } else if (event.key === "Home") {
177
+ event.preventDefault();
178
+ moveToEdge("start");
179
+ } else if (event.key === "End") {
180
+ event.preventDefault();
181
+ moveToEdge("end");
141
182
  } else if (event.key === "Enter" && flatOrdered[activeIndex]) {
142
183
  event.preventDefault();
143
184
  select(flatOrdered[activeIndex]);
@@ -168,6 +209,13 @@ function SearchSelect({
168
209
  variant: "outline",
169
210
  role: "combobox",
170
211
  "aria-expanded": open,
212
+ "aria-controls": open ? listId : void 0,
213
+ "aria-label": ariaLabel,
214
+ "aria-labelledby": ariaLabelledby,
215
+ "aria-describedby": ariaDescribedby,
216
+ "aria-errormessage": ariaErrorMessage,
217
+ "aria-invalid": ariaInvalid,
218
+ "aria-required": ariaRequired,
171
219
  disabled,
172
220
  "data-testid": dataTestId,
173
221
  className: cn(
@@ -195,11 +243,13 @@ function SearchSelect({
195
243
  /* @__PURE__ */ jsx(
196
244
  PopoverContent,
197
245
  {
246
+ "aria-label": ariaLabelledby ? void 0 : ariaLabel ?? resolvedPlaceholder,
247
+ "aria-labelledby": ariaLabelledby,
198
248
  align: "start",
199
249
  sideOffset: 4,
200
250
  collisionPadding: 12,
201
251
  className: "flex max-h-[var(--radix-popover-content-available-height)] w-max max-w-[min(32rem,calc(100vw-1.5rem))] min-w-[var(--radix-popover-trigger-width)] flex-col p-0",
202
- children: /* @__PURE__ */ jsxs(Command, { shouldFilter: false, className: "flex min-h-0 flex-col", children: [
252
+ children: /* @__PURE__ */ jsxs(Command, { value, shouldFilter: false, className: "flex min-h-0 flex-col", children: [
203
253
  /* @__PURE__ */ jsx("div", { className: "border-border shrink-0 border-b", children: /* @__PURE__ */ jsx(
204
254
  Input,
205
255
  {
@@ -207,6 +257,7 @@ function SearchSelect({
207
257
  "aria-controls": listId,
208
258
  "aria-autocomplete": "list",
209
259
  "aria-activedescendant": activeOptionId,
260
+ "aria-label": searchPlaceholder ?? t("dataEntry.searchSelect.search"),
210
261
  value: query,
211
262
  onChange: (event) => setQuery(event.target.value),
212
263
  onKeyDown,
@@ -215,32 +266,35 @@ function SearchSelect({
215
266
  }
216
267
  ) }),
217
268
  /* @__PURE__ */ jsxs(
218
- CommandList,
269
+ "div",
219
270
  {
220
271
  id: listId,
221
272
  role: "listbox",
273
+ "aria-busy": loading,
222
274
  className: "max-h-none min-h-0 flex-1 overflow-y-auto p-1",
223
275
  onScroll,
224
276
  children: [
225
277
  grouped.map((group) => {
226
278
  const rows = group.items.map(({ option, index }) => /* @__PURE__ */ jsx(
227
- CommandItem,
279
+ "div",
228
280
  {
229
281
  id: optionDomId(option.value),
230
282
  role: "option",
231
- value: option.value,
232
283
  "data-testid": optionTestId(option.value),
233
- "aria-selected": activeIndex === index,
234
- disabled: option.disabled,
284
+ "aria-selected": value === option.value,
285
+ "aria-disabled": option.disabled || void 0,
286
+ "data-disabled": option.disabled || void 0,
235
287
  className: cn(
288
+ "ui-command-item",
236
289
  // Selected = persistent bg-accent + medium weight (NO check icon — saves width),
237
290
  // matching the plain SelectItem's `data-[state=checked]` convention; active =
238
291
  // hover/keyboard accent. Same bg so selection stays coherent across both Selects.
239
292
  value === option.value && "bg-accent text-foreground font-medium",
240
293
  activeIndex === index && "bg-accent text-accent-foreground"
241
294
  ),
242
- onMouseEnter: () => setActiveIndex(index),
243
- onSelect: () => select(option),
295
+ onMouseEnter: () => !option.disabled && setActiveIndex(index),
296
+ onMouseDown: (event) => event.preventDefault(),
297
+ onClick: () => select(option),
244
298
  children: renderOption ? /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: renderOption(option) }) : /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: [
245
299
  option.icon ? /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", "aria-hidden": "true", children: option.icon }) : null,
246
300
  /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-col", children: [
@@ -253,11 +307,35 @@ function SearchSelect({
253
307
  ));
254
308
  return group.heading ? /* @__PURE__ */ jsx(CommandGroup, { heading: group.heading, children: rows }, group.heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rows }, "__ungrouped");
255
309
  }),
256
- loading ? /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground flex items-center gap-2 px-2 py-3 text-sm", children: [
257
- /* @__PURE__ */ jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" }),
258
- loadingMessage ?? t("dataEntry.searchSelect.loading")
259
- ] }) : null,
260
- !loading && loaded.length === 0 ? /* @__PURE__ */ jsx("div", { className: "text-muted-foreground px-2 py-6 text-center text-sm", children: emptyMessage ?? t("dataEntry.searchSelect.empty") }) : null
310
+ loading ? /* @__PURE__ */ jsxs(
311
+ "div",
312
+ {
313
+ role: "status",
314
+ className: "text-muted-foreground flex items-center gap-2 px-2 py-3 text-sm",
315
+ children: [
316
+ /* @__PURE__ */ jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" }),
317
+ loadingMessage ?? t("dataEntry.searchSelect.loading")
318
+ ]
319
+ }
320
+ ) : error ? /* @__PURE__ */ jsx(
321
+ "div",
322
+ {
323
+ role: "option",
324
+ "aria-disabled": "true",
325
+ "aria-selected": false,
326
+ className: "text-destructive px-2 py-6 text-center text-sm",
327
+ children: errorMessage ?? t("dataEntry.searchSelect.error")
328
+ }
329
+ ) : loaded.length === 0 ? /* @__PURE__ */ jsx(
330
+ "div",
331
+ {
332
+ role: "option",
333
+ "aria-disabled": "true",
334
+ "aria-selected": false,
335
+ className: "text-muted-foreground px-2 py-6 text-center text-sm",
336
+ children: emptyMessage ?? t("dataEntry.searchSelect.empty")
337
+ }
338
+ ) : null
261
339
  ]
262
340
  }
263
341
  )
@@ -267,13 +345,13 @@ function SearchSelect({
267
345
  ]
268
346
  }
269
347
  ),
270
- /* @__PURE__ */ jsx("div", { className: "pointer-events-none absolute inset-y-0 end-3 flex items-center", children: showClear ? /* @__PURE__ */ jsx(
348
+ /* @__PURE__ */ jsx("div", { className: "pointer-events-none absolute inset-y-0 end-2 flex items-center", children: showClear ? /* @__PURE__ */ jsx(
271
349
  "button",
272
350
  {
273
351
  type: "button",
274
352
  "aria-label": clearLabel ?? t("dataEntry.searchSelect.clear"),
275
353
  "data-testid": optionTestId("clear"),
276
- className: "pointer-events-auto flex size-4 items-center justify-center rounded-sm opacity-50 hover:opacity-100 focus-visible:opacity-100",
354
+ className: "pointer-events-auto flex size-6 items-center justify-center rounded-sm opacity-50 hover:opacity-100 focus-visible:opacity-100",
277
355
  onClick: clear,
278
356
  children: /* @__PURE__ */ jsx(X, { className: "size-4", "aria-hidden": "true" })
279
357
  }
@@ -35,7 +35,14 @@ const SelectTrigger = React.forwardRef(({ className, children, size = "md", ...p
35
35
  ...props,
36
36
  children: [
37
37
  children,
38
- /* @__PURE__ */ jsx(SelectPrimitive.Icon, { asChild: true, children: /* @__PURE__ */ jsx(ChevronDown, { className: "size-4 shrink-0 opacity-50", "aria-hidden": "true" }) })
38
+ /* @__PURE__ */ jsx(SelectPrimitive.Icon, { asChild: true, children: /* @__PURE__ */ jsx(
39
+ ChevronDown,
40
+ {
41
+ "data-slot": "select-chevron",
42
+ className: "size-4 shrink-0 opacity-50",
43
+ "aria-hidden": "true"
44
+ }
45
+ ) })
39
46
  ]
40
47
  }
41
48
  ));
@@ -154,6 +161,7 @@ function DataSelect({
154
161
  searchPlaceholder,
155
162
  emptyMessage,
156
163
  loadingMessage,
164
+ errorMessage,
157
165
  clearLabel,
158
166
  clearable,
159
167
  disabled,
@@ -167,6 +175,7 @@ function DataSelect({
167
175
  Object.entries(rest).filter(([key]) => key.startsWith("aria-"))
168
176
  );
169
177
  const searchable = showSearch ?? Boolean(loadOptions);
178
+ const hasOptions = options.length > 0;
170
179
  if (searchable) {
171
180
  return /* @__PURE__ */ jsx(
172
181
  SearchSelect,
@@ -184,13 +193,15 @@ function DataSelect({
184
193
  searchPlaceholder,
185
194
  emptyMessage,
186
195
  loadingMessage,
196
+ errorMessage,
187
197
  clearLabel,
188
198
  clearable,
189
- disabled,
199
+ disabled: disabled || !loadOptions && !hasOptions,
190
200
  name,
191
201
  id,
192
202
  className,
193
- "data-testid": dataTestId
203
+ "data-testid": dataTestId,
204
+ ...ariaProps
194
205
  }
195
206
  );
196
207
  }
@@ -216,7 +227,7 @@ function DataSelect({
216
227
  next,
217
228
  options.find((option) => option.value === next)
218
229
  ),
219
- disabled,
230
+ disabled: disabled || !hasOptions,
220
231
  name,
221
232
  children: [
222
233
  /* @__PURE__ */ jsx(SelectTrigger, { id, "data-testid": dataTestId, className, ...ariaProps, children: /* @__PURE__ */ jsx(SelectValue, { placeholder }) }),
@@ -26,8 +26,17 @@ export declare const AlertActions: React.ForwardRefExoticComponent<React.HTMLAtt
26
26
  className?: import("../../props/index.js").ClassNameProp;
27
27
  children?: import("../../props/index.js").ChildrenProp;
28
28
  } & React.RefAttributes<HTMLDivElement>>;
29
- /** TanStack Query / API failure preset — same visual as `Alert tone="destructive"`. Used by `DataState` (@godxjp/ui/query). */
30
- export declare function AlertQueryError({ error, onRetry, className }: AlertQueryErrorProp): import("react/jsx-runtime").JSX.Element;
29
+ /**
30
+ * TanStack Query / API failure preset used by `DataState` (@godxjp/ui/query).
31
+ *
32
+ * - **Cause-aware mode** (pass `category`, as `DataState` does): presents a safe, localized message
33
+ * — never the raw backend/token/stack text — with a cause-appropriate action. `auth`
34
+ * (401/expired) offers session renewal (`onAuthAction`) instead of Retry; transient/network/5xx
35
+ * offer Retry (`onRetry`); permission/not-found/validation offer neither by default.
36
+ * - **Legacy mode** (no `category`, e.g. mutation/infinite feedback): shows the cleaned domain
37
+ * message (`humanError`) + optional Retry — form-submit corrective guidance stays visible.
38
+ */
39
+ export declare function AlertQueryError({ error, category, onRetry, onAuthAction, className, }: AlertQueryErrorProp): import("react/jsx-runtime").JSX.Element;
31
40
  export declare const Alert: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLDivElement> & {
32
41
  variant?: import("../../props/index.js").AlertVariantProp;
33
42
  tone?: ToneProp;
@@ -5,6 +5,7 @@ import {
5
5
  AlertCircle,
6
6
  CheckCircle2,
7
7
  Info,
8
+ LogIn,
8
9
  RefreshCw,
9
10
  TriangleAlert,
10
11
  X
@@ -89,25 +90,66 @@ const AlertActions = React.forwardRef(
89
90
  ({ className, ...props }, ref) => /* @__PURE__ */ jsx("div", { ref, "data-slot": "alert-actions", className, ...props })
90
91
  );
91
92
  AlertActions.displayName = "AlertActions";
92
- function AlertQueryError({ error, onRetry, className }) {
93
+ const WARNING_CATEGORIES = /* @__PURE__ */ new Set([
94
+ "forbidden",
95
+ "notFound",
96
+ "validation"
97
+ ]);
98
+ const RETRYABLE_CATEGORIES = /* @__PURE__ */ new Set([
99
+ "transient",
100
+ "unknown"
101
+ ]);
102
+ function RetryButton({ onRetry }) {
93
103
  const { t } = useTranslation();
94
- return /* @__PURE__ */ jsxs(Alert, { tone: "destructive", className, children: [
95
- /* @__PURE__ */ jsx(AlertTitle, { children: t("common.error") }),
96
- /* @__PURE__ */ jsx(AlertDescription, { children: humanError(error) }),
97
- onRetry && /* @__PURE__ */ jsx(AlertActions, { children: /* @__PURE__ */ jsx(
104
+ return /* @__PURE__ */ jsx(AlertActions, { children: /* @__PURE__ */ jsx(
105
+ Button,
106
+ {
107
+ variant: "outline",
108
+ size: "sm",
109
+ onClick: () => {
110
+ void onRetry();
111
+ },
112
+ children: /* @__PURE__ */ jsxs(Flex, { direction: "row", wrap: true, align: "center", gap: "xs", children: [
113
+ /* @__PURE__ */ jsx(RefreshCw, { className: "size-4", "aria-hidden": "true" }),
114
+ t("common.retry")
115
+ ] })
116
+ }
117
+ ) });
118
+ }
119
+ function AlertQueryError({
120
+ error,
121
+ category,
122
+ onRetry,
123
+ onAuthAction,
124
+ className
125
+ }) {
126
+ const { t } = useTranslation();
127
+ if (!category) {
128
+ return /* @__PURE__ */ jsxs(Alert, { tone: "destructive", className, children: [
129
+ /* @__PURE__ */ jsx(AlertTitle, { children: t("common.error") }),
130
+ /* @__PURE__ */ jsx(AlertDescription, { children: humanError(error) }),
131
+ onRetry && /* @__PURE__ */ jsx(RetryButton, { onRetry })
132
+ ] });
133
+ }
134
+ const tone = WARNING_CATEGORIES.has(category) ? "warning" : "destructive";
135
+ return /* @__PURE__ */ jsxs(Alert, { tone, className, children: [
136
+ /* @__PURE__ */ jsx(AlertTitle, { children: t(`query.error.title.${category}`) }),
137
+ /* @__PURE__ */ jsx(AlertDescription, { children: t(`query.error.description.${category}`) }),
138
+ category === "auth" && onAuthAction && /* @__PURE__ */ jsx(AlertActions, { children: /* @__PURE__ */ jsx(
98
139
  Button,
99
140
  {
100
141
  variant: "outline",
101
142
  size: "sm",
102
143
  onClick: () => {
103
- void onRetry();
144
+ void onAuthAction();
104
145
  },
105
146
  children: /* @__PURE__ */ jsxs(Flex, { direction: "row", wrap: true, align: "center", gap: "xs", children: [
106
- /* @__PURE__ */ jsx(RefreshCw, { className: "size-4", "aria-hidden": "true" }),
107
- t("common.retry")
147
+ /* @__PURE__ */ jsx(LogIn, { className: "size-4", "aria-hidden": "true" }),
148
+ t("query.error.action.signIn")
108
149
  ] })
109
150
  }
110
- ) })
151
+ ) }),
152
+ RETRYABLE_CATEGORIES.has(category) && onRetry && /* @__PURE__ */ jsx(RetryButton, { onRetry })
111
153
  ] });
112
154
  }
113
155
  const Alert = Object.assign(AlertBase, {
@@ -62,6 +62,7 @@ const Button = React.forwardRef(
62
62
  overflowCount = 99,
63
63
  showZero = true,
64
64
  disabled,
65
+ type,
65
66
  children,
66
67
  ...props
67
68
  }, ref) => {
@@ -96,6 +97,7 @@ const Button = React.forwardRef(
96
97
  "data-loading": isLoading ? "" : void 0,
97
98
  "aria-busy": isLoading || void 0,
98
99
  disabled: isLoading || disabled,
100
+ type: asChild ? void 0 : type ?? "button",
99
101
  className: cn(
100
102
  "inline-flex shrink-0 items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-all outline-none",
101
103
  fullWidth && "w-full",
@@ -4,3 +4,5 @@ export { Text, Heading } from "./typography.js";
4
4
  export type { TextProps, HeadingProps, TextProp, HeadingProp } from "./typography.js";
5
5
  export { Logo } from "./logo.js";
6
6
  export type { LogoProps, LogoSize } from "./logo.js";
7
+ export { Reveal } from "./reveal.js";
8
+ export type { RevealProp, RevealProps } from "./reveal.js";
@@ -1,10 +1,12 @@
1
1
  import { Button, buttonVariants } from "./button.js";
2
2
  import { Text, Heading } from "./typography.js";
3
3
  import { Logo } from "./logo.js";
4
+ import { Reveal } from "./reveal.js";
4
5
  export {
5
6
  Button,
6
7
  Heading,
7
8
  Logo,
9
+ Reveal,
8
10
  Text,
9
11
  buttonVariants
10
12
  };
@@ -0,0 +1,14 @@
1
+ import type { RevealProp } from "../../props/components/general.prop.js";
2
+ export type { RevealProp, RevealProp as RevealProps } from "../../props/components/general.prop.js";
3
+ /**
4
+ * Reveal — the official entrance-motion primitive (staggered fade-up). Content animates in on mount
5
+ * reading the DS motion tokens (`--duration-slow`, `--ease-emphasized`, `--reveal-distance`), so a
6
+ * consumer never hand-rolls `@keyframes` + `.app-reveal`/`.d1..d6`. `delay` is a stagger ordinal
7
+ * (`0..6`) — an index into the `--reveal-stagger-step` ladder, never a raw ms — so a column of rows
8
+ * cascades in. Under `prefers-reduced-motion` the animation is dropped entirely: content renders in
9
+ * its final position, fully visible, with no layout shift (CSS `@media` guard in motion.css).
10
+ *
11
+ * Pure/server-safe: no hooks, no effects. Pass `asChild` to merge the reveal onto the single child
12
+ * element (no wrapper `<div>`) when an extra box would break a grid/flex layout.
13
+ */
14
+ export declare function Reveal({ children, delay, asChild, className, ...props }: RevealProp): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { cloneElement, isValidElement } from "react";
3
+ import { cn } from "../../lib/utils.js";
4
+ function Reveal({ children, delay = 0, asChild = false, className, ...props }) {
5
+ const shared = {
6
+ "data-slot": "reveal",
7
+ "data-reveal-delay": delay > 0 ? String(delay) : void 0
8
+ };
9
+ if (asChild && isValidElement(children)) {
10
+ const child = children;
11
+ return cloneElement(child, {
12
+ ...shared,
13
+ ...props,
14
+ className: cn("ui-reveal", child.props.className, className)
15
+ });
16
+ }
17
+ return /* @__PURE__ */ jsx("div", { ...shared, ...props, className: cn("ui-reveal", className), children });
18
+ }
19
+ export {
20
+ Reveal
21
+ };