@godxjp/ui 30.5.2 → 30.5.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.
@@ -3,7 +3,7 @@
3
3
  You are about to write code against a design system you did not author. This file is the whole
4
4
  contract. Read it before you write JSX.
5
5
 
6
- **This catalog describes `@godxjp/ui` 30.5.2.** If the project you are editing has a different
6
+ **This catalog describes `@godxjp/ui` 30.5.3.** If the project you are editing has a different
7
7
  version in its `package.json`, read the pinned catalog for THAT version instead
8
8
  (`…/v<their-version>/agent/…`). A catalog newer than the installed package describes props that do
9
9
  not exist yet; older, and it hides props that do. Neither failure announces itself.
@@ -27,7 +27,7 @@
27
27
  "type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
28
28
  },
29
29
  {
30
- "description": "Server fetcher, debounced. `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented.",
30
+ "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
31
31
  "name": "loadOptions",
32
32
  "type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
33
33
  },
@@ -66,6 +66,11 @@
66
66
  "name": "placeholder",
67
67
  "type": "string"
68
68
  },
69
+ {
70
+ "description": "Field name, normally supplied by FormField. Declared explicitly because BOTH branches must keep it: a server's 422 is pinned to `data-field`, and a consumer measured the dropdown branch dropping it while the dialog branch kept it (gh#942).",
71
+ "name": "data-field",
72
+ "type": "string"
73
+ },
69
74
  {
70
75
  "description": "Dialog heading. Defaults to the localized `dataEntry.recordPicker.dialogTitle`.",
71
76
  "name": "dialogTitle",
@@ -6013,7 +6013,7 @@
6013
6013
  "type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
6014
6014
  },
6015
6015
  {
6016
- "description": "Server fetcher, debounced. `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented.",
6016
+ "description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
6017
6017
  "name": "loadOptions",
6018
6018
  "type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
6019
6019
  },
@@ -6052,6 +6052,11 @@
6052
6052
  "name": "placeholder",
6053
6053
  "type": "string"
6054
6054
  },
6055
+ {
6056
+ "description": "Field name, normally supplied by FormField. Declared explicitly because BOTH branches must keep it: a server's 422 is pinned to `data-field`, and a consumer measured the dropdown branch dropping it while the dialog branch kept it (gh#942).",
6057
+ "name": "data-field",
6058
+ "type": "string"
6059
+ },
6055
6060
  {
6056
6061
  "description": "Dialog heading. Defaults to the localized `dataEntry.recordPicker.dialogTitle`.",
6057
6062
  "name": "dialogTitle",
package/agent/index.json CHANGED
@@ -48,7 +48,7 @@
48
48
  "note": "Pin to the tag that matches the @godxjp/ui version you installed. A catalog newer than your package describes props you do not have; older, and it hides props you do.",
49
49
  "read": {
50
50
  "live": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/index.json",
51
- "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.5.2/agent/index.json"
51
+ "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.5.3/agent/index.json"
52
52
  },
53
53
  "source": "mcp/src/data — the same data @godxjp/ui-mcp serves — plus the foundation and semantic token tiers, read from src/tokens/*.css",
54
54
  "start": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/START-HERE.md",
@@ -62,5 +62,5 @@
62
62
  "foundation": "the seeds a consumer is invited to set — --primary, --background, --radius",
63
63
  "semantic": "named roles that follow the seeds — --ring, --text-link, --overlay-background"
64
64
  },
65
- "version": "30.5.2"
65
+ "version": "30.5.3"
66
66
  }
package/agent/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @godxjp/ui
2
2
 
3
3
  > A Japanese-enterprise React design system: 173 components, 2073 design tokens,
4
- > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.5.2.
4
+ > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.5.3.
5
5
 
6
6
  If your client can run a process, do not read these files — run the MCP server instead
7
- (`npx @godxjp/ui-mcp@30.5.2`). It is searchable and version-locked. These files exist for agents
7
+ (`npx @godxjp/ui-mcp@30.5.3`). It is searchable and version-locked. These files exist for agents
8
8
  that can only fetch URLs.
9
9
 
10
10
  ## Start
@@ -26,7 +26,7 @@ that can only fetch URLs.
26
26
  ## Pinning
27
27
 
28
28
  Every URL above tracks `main`. To pin to the release a project actually installed, swap `main` for
29
- the tag: `.../godx-jp/godxjp-ui/v30.5.2/agent/...`. A catalog that does not match the installed
29
+ the tag: `.../godx-jp/godxjp-ui/v30.5.3/agent/...`. A catalog that does not match the installed
30
30
  package describes props that are absent, or hides props that are present, and says nothing either way.
31
31
 
32
32
  Pinned catalogs only exist for releases whose tag actually contains `agent/`. If `…/v<version>/agent/index.json` returns 404, that release predates this catalog: read `…/main/…` instead and compare `index.json` → `version` against the package you have, so you at least know which way it drifted.
@@ -59,4 +59,5 @@ export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.Bu
59
59
  placeholder?: string;
60
60
  dialogTitle?: string;
61
61
  size?: "xs" | "sm" | "md" | "lg";
62
+ "data-field"?: string;
62
63
  } & React.RefAttributes<HTMLButtonElement>>;
@@ -39,6 +39,7 @@ const RecordPicker = React.forwardRef(
39
39
  disabled = false,
40
40
  size,
41
41
  className,
42
+ "data-field": fieldName,
42
43
  ...props
43
44
  }, ref) {
44
45
  const { t } = useTranslation();
@@ -55,6 +56,17 @@ const RecordPicker = React.forwardRef(
55
56
  },
56
57
  [controlled, isMultiple, onValueChange]
57
58
  );
59
+ const selectLoadOptions = React.useMemo(
60
+ () => loadOptions ? async ({ query, page }) => {
61
+ const r = await loadOptions({
62
+ query,
63
+ filters: {},
64
+ cursor: page > 1 ? String(page) : void 0
65
+ });
66
+ return { options: r.options, hasMore: r.nextCursor !== void 0 };
67
+ } : void 0,
68
+ [loadOptions]
69
+ );
58
70
  const staticOptions = React.useMemo(
59
71
  () => options ? normalizeSelectOptions(options) : [],
60
72
  [options]
@@ -66,6 +78,10 @@ const RecordPicker = React.forwardRef(
66
78
  Select,
67
79
  {
68
80
  mode: isMultiple ? "multiple" : void 0,
81
+ loadOptions: selectLoadOptions,
82
+ id: props.id,
83
+ "data-field": fieldName,
84
+ "aria-describedby": props["aria-describedby"],
69
85
  labelInValue: false,
70
86
  showSearch: true,
71
87
  options: withEmptyOption(options, emptyOption),
@@ -82,6 +98,7 @@ const RecordPicker = React.forwardRef(
82
98
  DialogPicker,
83
99
  {
84
100
  ref,
101
+ "data-field": fieldName,
85
102
  isMultiple,
86
103
  selected,
87
104
  commit,
@@ -130,23 +147,30 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
130
147
  const [filterValues, setFilterValues] = React.useState({});
131
148
  const [rows, setRows] = React.useState(staticOptions);
132
149
  const [status, setStatus] = React.useState("idle");
150
+ const [cursor, setCursor] = React.useState(void 0);
133
151
  const [draft, setDraft] = React.useState(selected);
134
152
  React.useEffect(() => {
135
153
  if (open) setDraft(selected);
136
154
  }, [open, selected]);
137
- const labels = React.useMemo(() => {
138
- const map = /* @__PURE__ */ new Map();
139
- for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) map.set(o.value, o);
140
- if (emptyOption) map.set(emptyOption.value, { value: emptyOption.value, label: emptyOption.label });
141
- return map;
142
- }, [selectedOptions, staticOptions, rows, emptyOption]);
155
+ const labelCache = React.useRef(/* @__PURE__ */ new Map());
156
+ for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) {
157
+ labelCache.current.set(o.value, o);
158
+ }
159
+ if (emptyOption) {
160
+ labelCache.current.set(emptyOption.value, {
161
+ value: emptyOption.value,
162
+ label: emptyOption.label
163
+ });
164
+ }
165
+ const labels = labelCache.current;
143
166
  const load = React.useCallback(
144
- async (q, f) => {
167
+ async (q, f, more) => {
145
168
  if (!loadOptions) return;
146
169
  setStatus("loading");
147
170
  try {
148
- const result = await loadOptions({ query: q, filters: f });
149
- setRows(result.options);
171
+ const result = await loadOptions({ query: q, filters: f, cursor: more });
172
+ setRows((prev) => more ? [...prev, ...result.options] : result.options);
173
+ setCursor(result.nextCursor);
150
174
  setStatus("idle");
151
175
  } catch {
152
176
  setStatus("error");
@@ -183,27 +207,39 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
183
207
  };
184
208
  const chips = selected.map((v) => labels.get(v) ?? { value: v, label: v });
185
209
  return /* @__PURE__ */ jsxs(Fragment, { children: [
186
- /* @__PURE__ */ jsxs(
187
- Button,
188
- {
189
- ref,
190
- type: "button",
191
- variant: "outline",
192
- size,
193
- disabled,
194
- "aria-haspopup": "dialog",
195
- onClick: () => setOpen(true),
196
- className: cn("ui-record-picker-trigger", className),
197
- ...props,
198
- children: [
199
- /* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
200
- c.icon,
201
- c.label
202
- ] }, c.value)) }) }),
203
- /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
204
- ]
205
- }
206
- ),
210
+ /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", className: "ui-record-picker-row", children: [
211
+ /* @__PURE__ */ jsxs(
212
+ Button,
213
+ {
214
+ ref,
215
+ type: "button",
216
+ variant: "outline",
217
+ size,
218
+ disabled,
219
+ "aria-haspopup": "dialog",
220
+ onClick: () => setOpen(true),
221
+ className: cn("ui-record-picker-trigger", className),
222
+ ...props,
223
+ children: [
224
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
225
+ c.icon,
226
+ c.label
227
+ ] }, c.value)) }) }),
228
+ /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
229
+ ]
230
+ }
231
+ ),
232
+ !isMultiple && selected.length > 0 && !disabled ? /* @__PURE__ */ jsx(
233
+ Button,
234
+ {
235
+ variant: "ghost",
236
+ size: "icon-sm",
237
+ "aria-label": t("dataEntry.recordPicker.clear"),
238
+ onClick: () => commit([]),
239
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
240
+ }
241
+ ) : null
242
+ ] }),
207
243
  /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
208
244
  /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
209
245
  /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
@@ -218,28 +254,46 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
218
254
  placeholder: t("dataEntry.recordPicker.searchPlaceholder")
219
255
  }
220
256
  ),
221
- filters?.length ? /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "sm", wrap: true, role: "group", "aria-label": t("dataEntry.recordPicker.filters"), children: filters.map((f) => /* @__PURE__ */ jsx(
222
- Select,
257
+ filters?.length ? /* @__PURE__ */ jsx(
258
+ Flex,
223
259
  {
224
- size: "sm",
225
- "aria-label": f.label,
226
- placeholder: f.label,
227
- value: filterValues[f.name] ?? "",
228
- onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
229
- options: [
230
- { value: "", label: t("dataEntry.recordPicker.allFilter") },
231
- ...f.options
232
- ]
233
- },
234
- f.name
235
- )) }) : null,
260
+ direction: "row",
261
+ gap: "sm",
262
+ wrap: true,
263
+ role: "group",
264
+ "aria-label": t("dataEntry.recordPicker.filters"),
265
+ children: filters.map((f) => /* @__PURE__ */ jsx(
266
+ Select,
267
+ {
268
+ size: "sm",
269
+ "aria-label": f.label,
270
+ placeholder: f.label,
271
+ value: filterValues[f.name] ?? "",
272
+ onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
273
+ options: [
274
+ { value: "", label: t("dataEntry.recordPicker.allFilter") },
275
+ ...f.options
276
+ ]
277
+ },
278
+ f.name
279
+ ))
280
+ }
281
+ ) : null,
236
282
  /* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
237
283
  EmptyState,
238
284
  {
239
285
  variant: "compact",
240
286
  tone: "destructive",
241
287
  title: t("dataEntry.recordPicker.error"),
242
- action: /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => void load(query, filterValues), children: t("dataEntry.recordPicker.more") })
288
+ action: /* @__PURE__ */ jsx(
289
+ Button,
290
+ {
291
+ variant: "outline",
292
+ size: "sm",
293
+ onClick: () => void load(query, filterValues),
294
+ children: t("dataEntry.recordPicker.more")
295
+ }
296
+ )
243
297
  }
244
298
  ) : status === "loading" ? /* @__PURE__ */ jsx("div", { role: "status", className: "ui-record-picker-status", children: t("dataEntry.recordPicker.loading") }) : visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "compact", title: t("dataEntry.recordPicker.empty") }) : groups.map(([heading, items]) => {
245
299
  const rendered = items.map((o) => {
@@ -267,7 +321,16 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
267
321
  );
268
322
  });
269
323
  return heading ? /* @__PURE__ */ jsx(CommandGroup, { heading, children: rendered }, heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rendered }, "__ungrouped");
270
- }) })
324
+ }) }),
325
+ cursor !== void 0 && status !== "loading" ? /* @__PURE__ */ jsx(
326
+ Button,
327
+ {
328
+ variant: "outline",
329
+ size: "sm",
330
+ onClick: () => void load(query, filterValues, cursor),
331
+ children: t("dataEntry.recordPicker.more")
332
+ }
333
+ ) : null
271
334
  ] }) }),
272
335
  isMultiple ? /* @__PURE__ */ jsxs(DialogFooter, { children: [
273
336
  /* @__PURE__ */ jsx(Text, { as: "span", size: "xs", tone: "muted", className: "me-auto", children: t("dataEntry.recordPicker.selected", { count: draft.length }) }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "30.5.2",
3
+ "version": "30.5.3",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -1186,6 +1186,12 @@ export type RecordPickerProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement
1186
1186
  placeholder?: string;
1187
1187
  dialogTitle?: string;
1188
1188
  size?: "xs" | "sm" | "md" | "lg";
1189
+ /**
1190
+ * Tên trường, do `FormField` truyền xuống. Khai tường minh vì CẢ HAI nhánh phải giữ nó: một
1191
+ * lỗi 422 của server bám theo `data-field`, và consumer đo được nhánh dropdown đánh rơi nó
1192
+ * trong khi nhánh Dialog thì giữ (gh#942).
1193
+ */
1194
+ "data-field"?: string;
1189
1195
  };
1190
1196
  export type SearchSelectLoadParamsProp = {
1191
1197
  query: string;
@@ -835,3 +835,14 @@
835
835
  font-size: var(--font-size-sm);
836
836
  }
837
837
  }
838
+
839
+ @layer components {
840
+
841
+ .ui-record-picker-row {
842
+ inline-size: 100%;
843
+ }
844
+ .ui-record-picker-row > .ui-record-picker-trigger {
845
+ flex: 1 1 auto;
846
+ min-inline-size: 0;
847
+ }
848
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "30.5.2",
4
- "godxUiMcp": "30.5.2",
3
+ "version": "30.5.3",
4
+ "godxUiMcp": "30.5.3",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",