@godxjp/ui 26.2.0 → 26.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,7 @@ export declare const ChatComposer: React.ForwardRefExoticComponent<Omit<React.HT
31
31
  onCancel?: () => void;
32
32
  loading?: import("../../props/index.js").PendingProp;
33
33
  submitType?: import("./chat-composer.js").ChatComposerSubmitTypeProp;
34
+ allowEmptySubmit?: boolean;
34
35
  placeholder?: import("../../props/index.js").PlaceholderProp;
35
36
  disabled?: import("../../props/index.js").DisabledProp;
36
37
  readOnly?: boolean;
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { SendHorizontal, Square } from "lucide-react";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
6
  import { cn } from "../../lib/utils.js";
7
+ import { isApplePlatform } from "../../lib/platform.js";
7
8
  import { omitFieldA11y, pickFieldA11y, useFieldIdentity } from "../../lib/field-a11y.js";
8
9
  import { Button } from "../general/button.js";
9
10
  import { Textarea } from "./textarea.js";
@@ -23,6 +24,7 @@ const ChatComposer = React.forwardRef(
23
24
  onCancel,
24
25
  loading = false,
25
26
  submitType = "enter",
27
+ allowEmptySubmit = false,
26
28
  placeholder,
27
29
  disabled = false,
28
30
  readOnly = false,
@@ -57,12 +59,13 @@ const ChatComposer = React.forwardRef(
57
59
  const rest = omitFieldA11y(props);
58
60
  const identity = useFieldIdentity({ id, name, "data-field": props["data-field"] });
59
61
  const surface = controlSurfaceAttrs({ status, size });
60
- const canSubmit = isSendable(draft) && !disabled && !readOnly && !loading;
62
+ const canSubmit = (allowEmptySubmit || isSendable(draft)) && !disabled && !readOnly && !loading;
61
63
  const submit = React.useCallback(() => {
64
+ if (disabled || readOnly || loading) return;
62
65
  const text = innerRef.current?.value ?? draft;
63
- if (!isSendable(text) || disabled || readOnly || loading) return;
64
- onSubmit?.(text);
65
- }, [draft, disabled, readOnly, loading, onSubmit]);
66
+ if (isSendable(text)) onSubmit?.(text);
67
+ else if (allowEmptySubmit) onSubmit?.("");
68
+ }, [draft, disabled, readOnly, loading, allowEmptySubmit, onSubmit]);
66
69
  const handleKeyDown = (event) => {
67
70
  onKeyDown?.(event);
68
71
  if (event.defaultPrevented) return;
@@ -70,7 +73,11 @@ const ChatComposer = React.forwardRef(
70
73
  if (composing.current || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) {
71
74
  return;
72
75
  }
73
- const wantsSend = submitType === "enter" ? !event.shiftKey : event.shiftKey;
76
+ const wantsSend = submitType === "modEnter" ? (
77
+ // ⌘ on Apple platforms, Ctrl everywhere else — the other one is left alone, because
78
+ // Ctrl+Enter on a Mac is not the convention a Mac user reaches for.
79
+ isApplePlatform() ? event.metaKey : event.ctrlKey
80
+ ) : submitType === "enter" ? !event.shiftKey : event.shiftKey;
74
81
  if (!wantsSend) return;
75
82
  event.preventDefault();
76
83
  submit();
@@ -3,5 +3,7 @@ export type { AlertMutationFeedbackProp, AlertMutationFeedbackProp as AlertMutat
3
3
  /**
4
4
  * Inline mutation error — renders nothing when idle/success.
5
5
  * Prefer toast for transient saves; use this for blocking form sections (SimulatorPage).
6
+ * Inside a form that received a server error bag (`FormRoot errors` / `Form errors`), a validation
7
+ * error is the fields' to show, so it is skipped by default (gh#690).
6
8
  */
7
- export declare function AlertMutationFeedback({ mutation, onRetry, showRetry, pending, className, }: AlertMutationFeedbackProp): import("react").JSX.Element | null;
9
+ export declare function AlertMutationFeedback({ mutation, onRetry, showRetry, pending, ignoreValidationErrors, className, }: AlertMutationFeedbackProp): import("react").JSX.Element | null;
@@ -1,15 +1,25 @@
1
1
  "use client";
2
2
  import { Fragment, jsx } from "react/jsx-runtime";
3
3
  import { AlertQueryError } from "../feedback/alert.js";
4
+ import { useFormErrorsRegistry } from "../data-entry/form-errors.js";
5
+ import { classifyQueryError } from "../../lib/query-error.js";
4
6
  function AlertMutationFeedback({
5
7
  mutation,
6
8
  onRetry,
7
9
  showRetry = true,
8
10
  pending,
11
+ ignoreValidationErrors,
9
12
  className
10
13
  }) {
14
+ const registry = useFormErrorsRegistry();
15
+ const bagShowsErrors = registry !== null && Object.values(registry.errors).some(
16
+ (entry) => Array.isArray(entry) ? entry.some(Boolean) : Boolean(entry)
17
+ );
11
18
  if (mutation.isPending && pending) return /* @__PURE__ */ jsx(Fragment, { children: pending });
12
19
  if (!mutation.isError || mutation.error == null) return null;
20
+ if ((ignoreValidationErrors ?? bagShowsErrors) && classifyQueryError(mutation.error).category === "validation") {
21
+ return null;
22
+ }
13
23
  return /* @__PURE__ */ jsx(
14
24
  AlertQueryError,
15
25
  {
@@ -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": "26.2.0",
3
+ "version": "26.4.0",
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,
@@ -169,7 +169,8 @@
169
169
  "send": "Send message",
170
170
  "cancel": "Stop generating",
171
171
  "hintEnter": "Enter to send · Shift + Enter for a new line",
172
- "hintShiftEnter": "Shift + Enter to send · Enter for a new line"
172
+ "hintShiftEnter": "Shift + Enter to send · Enter for a new line",
173
+ "hintModEnter": "{modifier} + Enter to send · Enter for a new line"
173
174
  },
174
175
  "chatSuggestion": {
175
176
  "label": "Suggestions",
@@ -166,7 +166,8 @@
166
166
  "send": "メッセージを送信",
167
167
  "cancel": "生成を停止",
168
168
  "hintEnter": "Enter で送信 · Shift + Enter で改行",
169
- "hintShiftEnter": "Shift + Enter で送信 · Enter で改行"
169
+ "hintShiftEnter": "Shift + Enter で送信 · Enter で改行",
170
+ "hintModEnter": "{modifier} + Enter で送信 · Enter で改行"
170
171
  },
171
172
  "chatSuggestion": {
172
173
  "label": "候補",
@@ -166,7 +166,8 @@
166
166
  "send": "Gửi tin nhắn",
167
167
  "cancel": "Dừng tạo câu trả lời",
168
168
  "hintEnter": "Enter để gửi · Shift + Enter để xuống dòng",
169
- "hintShiftEnter": "Shift + Enter để gửi · Enter để xuống dòng"
169
+ "hintShiftEnter": "Shift + Enter để gửi · Enter để xuống dòng",
170
+ "hintModEnter": "{modifier} + Enter để gửi · Enter để xuống dòng"
170
171
  },
171
172
  "chatSuggestion": {
172
173
  "label": "Gợi ý",
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Whether the user agent runs on an Apple platform (macOS, iOS, iPadOS), where the primary
3
+ * shortcut modifier is ⌘ (`metaKey`) rather than Ctrl (`ctrlKey`).
4
+ *
5
+ * `navigator.userAgentData.platform` is the standards answer (Chromium); `navigator.platform` is
6
+ * the deprecated but universally present fallback (Safari and Firefox still report `MacIntel` /
7
+ * `iPhone` / `iPad` there). Outside a browser there is no platform, so the answer is `false` — a
8
+ * server render therefore describes the Ctrl shortcut.
9
+ */
10
+ export declare function isApplePlatform(): boolean;
@@ -0,0 +1,9 @@
1
+ function isApplePlatform() {
2
+ if (typeof navigator === "undefined") return false;
3
+ const data = navigator.userAgentData;
4
+ const platform = data?.platform || navigator.platform || "";
5
+ return /mac|iphone|ipad|ipod/i.test(platform);
6
+ }
7
+ export {
8
+ isApplePlatform
9
+ };
@@ -1,2 +1,3 @@
1
1
  import { type ClassValue } from "clsx";
2
2
  export declare function cn(...inputs: ClassValue[]): string;
3
+ export { isApplePlatform } from "./platform.js";
package/dist/lib/utils.js CHANGED
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
3
3
  function cn(...inputs) {
4
4
  return twMerge(clsx(inputs));
5
5
  }
6
+ import { isApplePlatform } from "./platform.js";
6
7
  export {
7
- cn
8
+ cn,
9
+ isApplePlatform
8
10
  };
@@ -294,9 +294,9 @@ export type FormFieldProp = {
294
294
  *
295
295
  * `before` is for a helper the reader needs BEFORE they answer rather than after: the
296
296
  * secondary language of a bilingual form, a unit or format note, a pick-one-of-these
297
- * preamble. `labelAddon` cannot carry that — it is an inline row beside the label with no
298
- * wrap, sized for a chip or a help button, so a full sentence squeezes the label instead of
299
- * taking its own line. Putting the second line inside `label` does work, but costs the
297
+ * preamble. `labelAddon` cannot carry that — it belongs to the label row, sized for a chip, a
298
+ * help button or a short text action; in a horizontal/inline field it wraps under the label
299
+ * inside the label column, so a full sentence stacks there instead of above the input. Putting the second line inside `label` does work, but costs the
300
300
  * string-label fallbacks (`aria-label`, `FieldNameContext`), which fire only when `label` is
301
301
  * a plain string.
302
302
  *
@@ -308,7 +308,11 @@ export type FormFieldProp = {
308
308
  validateStatus?: "success" | "warning" | "error" | "validating";
309
309
  hasFeedback?: boolean;
310
310
  feedback?: React.ReactNode;
311
- /** Optional control rendered inline after the label (e.g. a help button). */
311
+ /**
312
+ * Optional control rendered after the label (e.g. a help button, a short text action). In a
313
+ * horizontal/inline field the label row wraps: an addon that does not fit beside the label
314
+ * drops to its own line under it, capped to the label column, never into the control column.
315
+ */
312
316
  labelAddon?: React.ReactNode;
313
317
  /** Override the Form's layout for this field only. */
314
318
  layout?: FormLayoutProp;
@@ -341,9 +345,9 @@ export type FormFieldProp = {
341
345
  *
342
346
  * `before` is for a helper the reader needs BEFORE they answer rather than after: the
343
347
  * secondary language of a bilingual form, a unit or format note, a pick-one-of-these
344
- * preamble. `labelAddon` cannot carry that — it is an inline row beside the label with no
345
- * wrap, sized for a chip or a help button, so a full sentence squeezes the label instead of
346
- * taking its own line. Putting the second line inside `label` does work, but costs the
348
+ * preamble. `labelAddon` cannot carry that — it belongs to the label row, sized for a chip, a
349
+ * help button or a short text action; in a horizontal/inline field it wraps under the label
350
+ * inside the label column, so a full sentence stacks there instead of above the input. Putting the second line inside `label` does work, but costs the
347
351
  * string-label fallbacks (`aria-label`, `FieldNameContext`), which fire only when `label` is
348
352
  * a plain string.
349
353
  *
@@ -355,7 +359,11 @@ export type FormFieldProp = {
355
359
  validateStatus?: "success" | "warning" | "error" | "validating";
356
360
  hasFeedback?: boolean;
357
361
  feedback?: React.ReactNode;
358
- /** Optional control rendered inline after the label (e.g. a help button). */
362
+ /**
363
+ * Optional control rendered after the label (e.g. a help button, a short text action). In a
364
+ * horizontal/inline field the label row wraps: an addon that does not fit beside the label
365
+ * drops to its own line under it, capped to the label column, never into the control column.
366
+ */
359
367
  labelAddon?: React.ReactNode;
360
368
  /** Override the Form's layout for this field only. */
361
369
  layout?: FormLayoutProp;
@@ -1774,10 +1782,13 @@ export type BranchScopePickerProp = FieldA11yProps & {
1774
1782
  *
1775
1783
  * `enter` (default) is the chat convention: `Enter` sends, `Shift+Enter` inserts a newline.
1776
1784
  * `shiftEnter` is the inverse, for composers that hold long, deliberately multi-line drafts.
1777
- * Neither ever fires while an IME conversion is in flight.
1785
+ * `modEnter` is this library's extension (antd X has only the first two; see
1786
+ * docs/DESIGN-AUTHORITY.md): `⌘+Enter` on Apple platforms, `Ctrl+Enter` elsewhere sends, while
1787
+ * `Enter` and `Shift+Enter` both insert a newline — the record-comment convention.
1788
+ * None of them ever fires while an IME conversion is in flight.
1778
1789
  * @see ChatComposer
1779
1790
  */
1780
- export type ChatComposerSubmitTypeProp = "enter" | "shiftEnter";
1791
+ export type ChatComposerSubmitTypeProp = "enter" | "shiftEnter" | "modEnter";
1781
1792
  /**
1782
1793
  * @see ChatComposer — the message input of a conversation (Ant Design X `Sender`; the industry
1783
1794
  * calls the control a *composer*, so that is what it is named).
@@ -1797,7 +1808,8 @@ export type ChatComposerProp = Omit<React.HTMLAttributes<HTMLDivElement>, "onSub
1797
1808
  onValueChange?: OnValueChangeProp<string>;
1798
1809
  /**
1799
1810
  * Send the draft. Receives the text as typed; never fires for an empty or whitespace-only
1800
- * draft, and never while `loading`, `disabled` or `readOnly`.
1811
+ * draft (unless `allowEmptySubmit`, which then passes `""`), and never while `loading`,
1812
+ * `disabled` or `readOnly`.
1801
1813
  */
1802
1814
  onSubmit?: (value: string) => void;
1803
1815
  /** Stop the in-flight response. Only reachable while `loading`. */
@@ -1809,6 +1821,13 @@ export type ChatComposerProp = Omit<React.HTMLAttributes<HTMLDivElement>, "onSub
1809
1821
  loading?: PendingProp;
1810
1822
  /** Which keystroke sends and which breaks the line. Default `enter`. */
1811
1823
  submitType?: ChatComposerSubmitTypeProp;
1824
+ /**
1825
+ * Let an empty or whitespace-only draft be sent — for a composer whose `header`/`footer` carry
1826
+ * payload of their own (a status change on a record). The send button stays enabled and both
1827
+ * the button and the keyboard submit call `onSubmit("")`. Still blocked while `loading`,
1828
+ * `disabled` or `readOnly`. Default `false`.
1829
+ */
1830
+ allowEmptySubmit?: boolean;
1812
1831
  /** Empty-state text of the draft box; pass it through `t()` at the call site. */
1813
1832
  placeholder?: PlaceholderProp;
1814
1833
  /** Disable the whole composer (draft box and every action). */
@@ -31,6 +31,13 @@ export type AlertMutationFeedbackProp = {
31
31
  showRetry?: boolean;
32
32
  /** Optional inline pending slot while `mutation.isPending`. */
33
33
  pending?: React.ReactNode;
34
+ /**
35
+ * Skip rendering when the error classifies as a validation error (`classifyQueryError` category
36
+ * `"validation"`: 400/422). `true` skips every such error. When omitted, the alert is skipped only
37
+ * inside a `FormRoot`/`Form` whose `errors` bag holds at least one message (the fields show it;
38
+ * `FormErrors` shows unclaimed keys); an empty/absent bag still renders the alert.
39
+ */
40
+ ignoreValidationErrors?: boolean;
34
41
  className?: ClassNameProp;
35
42
  };
36
43
  type QueryRefetchLike = Pick<UseQueryResult<unknown>, "isFetching" | "refetch">;
@@ -2048,6 +2048,10 @@ export declare const COMPONENT_PROP_REGISTRY: {
2048
2048
  readonly field: "pending";
2049
2049
  readonly local: true;
2050
2050
  readonly reason: "Inline pending ReactNode slot, not boolean PendingProp state.";
2051
+ }, {
2052
+ readonly field: "ignoreValidationErrors";
2053
+ readonly local: true;
2054
+ readonly reason: "Query-error category filter; defaults on inside a Form whose error bag holds messages.";
2051
2055
  }, "ClassNameProp"];
2052
2056
  };
2053
2057
  readonly ButtonRefetchProp: {
@@ -2401,6 +2401,11 @@ const COMPONENT_PROP_REGISTRY = {
2401
2401
  local: true,
2402
2402
  reason: "Inline pending ReactNode slot, not boolean PendingProp state."
2403
2403
  },
2404
+ {
2405
+ field: "ignoreValidationErrors",
2406
+ local: true,
2407
+ reason: "Query-error category filter; defaults on inside a Form whose error bag holds messages."
2408
+ },
2404
2409
  "ClassNameProp"
2405
2410
  ]
2406
2411
  },
@@ -1060,7 +1060,7 @@
1060
1060
  border: 0;
1061
1061
  padding: 0;
1062
1062
  cursor: pointer;
1063
- color: hsl(var(--muted-foreground) / 0.45);
1063
+ color: hsl(var(--rating-star-empty-color, var(--muted-foreground)) / var(--rating-star-empty-alpha));
1064
1064
  line-height: 0;
1065
1065
  }
1066
1066
  .ui-rating-star:disabled {
@@ -1072,7 +1072,7 @@
1072
1072
  }
1073
1073
 
1074
1074
  .ui-rating-star-filled {
1075
- color: hsl(var(--warning));
1075
+ color: hsl(var(--rating-star-filled-color, var(--warning)));
1076
1076
  }
1077
1077
  .ui-rating-star-filled svg {
1078
1078
  fill: currentColor;
@@ -3017,7 +3017,7 @@
3017
3017
  }
3018
3018
 
3019
3019
  .ui-rating-star-half-filled {
3020
- color: hsl(var(--warning));
3020
+ color: hsl(var(--rating-star-filled-color, var(--warning)));
3021
3021
  }
3022
3022
 
3023
3023
  .ui-radio-button-bar {
@@ -99,9 +99,16 @@
99
99
  column-gap: var(--form-label-gap);
100
100
  row-gap: var(--space-stack-xs);
101
101
  }
102
+
102
103
  .ui-form-field[data-collapse-below="false"]:is([data-layout="horizontal"], [data-layout="inline"])
103
104
  > .ui-form-field-label {
104
105
  min-block-size: var(--control-height);
106
+ flex-wrap: wrap;
107
+ }
108
+ .ui-form-field[data-collapse-below="false"]:is([data-layout="horizontal"], [data-layout="inline"])
109
+ > .ui-form-field-label
110
+ > * {
111
+ max-inline-size: 100%;
105
112
  }
106
113
  .ui-form-field[data-collapse-below="false"][data-label-align="end"]:is(
107
114
  [data-layout="horizontal"],
@@ -125,6 +132,12 @@
125
132
  .ui-form-field[data-collapse-below="sm"]:is([data-layout="horizontal"], [data-layout="inline"])
126
133
  > .ui-form-field-label {
127
134
  min-block-size: var(--control-height);
135
+ flex-wrap: wrap;
136
+ }
137
+ .ui-form-field[data-collapse-below="sm"]:is([data-layout="horizontal"], [data-layout="inline"])
138
+ > .ui-form-field-label
139
+ > * {
140
+ max-inline-size: 100%;
128
141
  }
129
142
  .ui-form-field[data-collapse-below="sm"][data-label-align="end"]:is(
130
143
  [data-layout="horizontal"],
@@ -149,6 +162,12 @@
149
162
  .ui-form-field[data-collapse-below="md"]:is([data-layout="horizontal"], [data-layout="inline"])
150
163
  > .ui-form-field-label {
151
164
  min-block-size: var(--control-height);
165
+ flex-wrap: wrap;
166
+ }
167
+ .ui-form-field[data-collapse-below="md"]:is([data-layout="horizontal"], [data-layout="inline"])
168
+ > .ui-form-field-label
169
+ > * {
170
+ max-inline-size: 100%;
152
171
  }
153
172
  .ui-form-field[data-collapse-below="md"][data-label-align="end"]:is(
154
173
  [data-layout="horizontal"],
@@ -173,6 +192,12 @@
173
192
  .ui-form-field[data-collapse-below="lg"]:is([data-layout="horizontal"], [data-layout="inline"])
174
193
  > .ui-form-field-label {
175
194
  min-block-size: var(--control-height);
195
+ flex-wrap: wrap;
196
+ }
197
+ .ui-form-field[data-collapse-below="lg"]:is([data-layout="horizontal"], [data-layout="inline"])
198
+ > .ui-form-field-label
199
+ > * {
200
+ max-inline-size: 100%;
176
201
  }
177
202
  .ui-form-field[data-collapse-below="lg"][data-label-align="end"]:is(
178
203
  [data-layout="horizontal"],
@@ -197,6 +222,12 @@
197
222
  .ui-form-field[data-collapse-below="xl"]:is([data-layout="horizontal"], [data-layout="inline"])
198
223
  > .ui-form-field-label {
199
224
  min-block-size: var(--control-height);
225
+ flex-wrap: wrap;
226
+ }
227
+ .ui-form-field[data-collapse-below="xl"]:is([data-layout="horizontal"], [data-layout="inline"])
228
+ > .ui-form-field-label
229
+ > * {
230
+ max-inline-size: 100%;
200
231
  }
201
232
  .ui-form-field[data-collapse-below="xl"][data-label-align="end"]:is(
202
233
  [data-layout="horizontal"],
@@ -82,6 +82,10 @@
82
82
 
83
83
  --rating-focus-ring-offset: 2px;
84
84
 
85
+ --rating-star-filled-color: initial;
86
+ --rating-star-empty-color: initial;
87
+ --rating-star-empty-alpha: 0.45;
88
+
85
89
  --checkbox-border-width: var(--stroke-hairline);
86
90
  --checkbox-size: calc(1rem * var(--scaling));
87
91
  --checkbox-size-compact: 0.875rem;
@@ -257,6 +257,15 @@ in page CSS.
257
257
  `onClose`, because `onClose` already means overlay dismiss across Dialog/Drawer and would read
258
258
  as closing a surface, not removing one applied filter chip. Implemented on `Badge` — the DS chip
259
259
  primitive — rather than adding a separate `Tag` export beside `Badge`.
260
+ - **`ChatComposer.submitType="modEnter"` and `ChatComposer.allowEmptySubmit`.** Ant Design X
261
+ `Sender.submitType` is only `"enter" | "shiftEnter"`, and `Sender` never submits an empty draft.
262
+ A comment composer on a record needs the convention GitHub, Jira, Linear and GitLab share —
263
+ `Enter` breaks the line, `⌘+Enter` (Apple platforms, `metaKey`) / `Ctrl+Enter` (elsewhere,
264
+ `ctrlKey`) sends — and needs to post a status change with no text (gh#693). `"modEnter"` extends
265
+ antd's union rather than renaming it, so both antd values keep their meaning; its hint is
266
+ `dataEntry.chatComposer.hintModEnter` with `{modifier}` from `isApplePlatform()`
267
+ (`@godxjp/ui/lib/utils`). `allowEmptySubmit` (default `false`, antd's behaviour) sends `""`,
268
+ still never while `loading` / `disabled` / `readOnly`.
260
269
 
261
270
  **A knob that only a fork could reach is not parity either.** antd's `components`,
262
271
  `filterDropdown`, `classNames`/`styles` semantic maps and `prefixCls` all exist to let a consumer
@@ -14,9 +14,12 @@ import {
14
14
  ChatComposer,
15
15
  ChatSuggestion,
16
16
  FormField,
17
+ Select,
17
18
  type ChatSuggestionItemProp,
18
19
  } from "@godxjp/ui/data-entry";
19
20
  import { Button, Text } from "@godxjp/ui/general";
21
+ import { useTranslation } from "@godxjp/ui/i18n";
22
+ import { isApplePlatform } from "@godxjp/ui/lib/utils";
20
23
  import {
21
24
  AppShell,
22
25
  Flex,
@@ -34,7 +37,8 @@ import { Bot, MessageSquare, Paperclip, Settings, Smile, Sparkles, Users } from
34
37
  *
35
38
  * The first card is the real screen: a live assistant transcript whose composer sends, streams and
36
39
  * cancels, with `/` slash commands and `@` mentions wired to the same draft. Every card after it
37
- * exists so the whole API is visible AT REST — both `submitType` values, all four `size` steps,
40
+ * exists so the whole API is visible AT REST — every `submitType` value (including the record-
41
+ * comment bar: `modEnter` + `allowEmptySubmit`), all four `size` steps,
38
42
  * every slot, and each non-default state — without having to click anything.
39
43
  *
40
44
  * Composed only from real @godxjp/ui components.
@@ -91,6 +95,20 @@ const OPENING: Message[] = [
91
95
  },
92
96
  ];
93
97
 
98
+ /** Record statuses for the comment bar — a status change is postable without any text. */
99
+ const STATUSES = [
100
+ { value: "open", label: "未対応" },
101
+ { value: "inProgress", label: "処理中" },
102
+ { value: "resolved", label: "処理済み" },
103
+ { value: "closed", label: "完了" },
104
+ ];
105
+
106
+ interface Comment {
107
+ id: number;
108
+ body: string;
109
+ status: string | null;
110
+ }
111
+
94
112
  /** One transcript line. The message FEED is ChatBubbleList's job; here it only sets the scene. */
95
113
  function Line({ message }: { message: Message }) {
96
114
  const mine = message.author === "you";
@@ -110,6 +128,8 @@ function Line({ message }: { message: Message }) {
110
128
  }
111
129
 
112
130
  export default function Demo() {
131
+ const { t } = useTranslation();
132
+
113
133
  // ── Card 1: the live screen ───────────────────────────────────────────────────────────────
114
134
  const [messages, setMessages] = useState<Message[]>(OPENING);
115
135
  const [draft, setDraft] = useState("");
@@ -142,6 +162,24 @@ export default function Demo() {
142
162
  const [shiftDraft, setShiftDraft] = useState("Enter は改行、Shift + Enter で送信");
143
163
  const [lastSent, setLastSent] = useState<string>("—");
144
164
 
165
+ // ── Card 2b: the record-comment bar (modEnter + allowEmptySubmit) ──────────────────────────
166
+ const [commentDraft, setCommentDraft] = useState("");
167
+ const [status, setStatus] = useState("open");
168
+ const [savedStatus, setSavedStatus] = useState("open");
169
+ const [comments, setComments] = useState<Comment[]>([]);
170
+
171
+ function postComment(text: string) {
172
+ const changed = status !== savedStatus;
173
+ // An empty draft with no status change carries nothing — the consumer decides, not the box.
174
+ if (!text && !changed) return;
175
+ setComments((current) => [
176
+ ...current,
177
+ { id: current.length + 1, body: text, status: changed ? status : null },
178
+ ]);
179
+ setSavedStatus(status);
180
+ setCommentDraft("");
181
+ }
182
+
145
183
  // ── Card 3: the four size steps ───────────────────────────────────────────────────────────
146
184
  const [sizeDrafts, setSizeDrafts] = useState<Record<string, string>>({
147
185
  xs: "xs",
@@ -286,6 +324,68 @@ export default function Demo() {
286
324
  </CardContent>
287
325
  </Card>
288
326
 
327
+ {/* ── 2b. Record comment bar ───────────────────────────────────────────────────── */}
328
+ <Card>
329
+ <CardHeader>
330
+ <CardTitle level={2}>
331
+ submitType=&quot;modEnter&quot; · allowEmptySubmit · 課題へのコメント
332
+ </CardTitle>
333
+ <CardDescription>
334
+ Enter と Shift + Enter は改行、⌘ + Enter(Mac)/ Ctrl + Enter(Windows・Linux)で
335
+ 投稿します。allowEmptySubmit により本文が空でも送信でき、ステータスだけを
336
+ 変更できます(onSubmit には空文字が渡ります)。
337
+ </CardDescription>
338
+ </CardHeader>
339
+ <CardContent>
340
+ <Flex direction="col" gap="md">
341
+ {comments.length === 0 ? (
342
+ <Text size="sm" tone="muted">
343
+ まだコメントはありません
344
+ </Text>
345
+ ) : (
346
+ comments.map((comment) => (
347
+ <Flex key={comment.id} direction="col" gap="xs">
348
+ {comment.status ? (
349
+ <Badge tone="info">
350
+ ステータス:
351
+ {STATUSES.find((option) => option.value === comment.status)?.label}
352
+ </Badge>
353
+ ) : null}
354
+ {comment.body ? <Text>{comment.body}</Text> : null}
355
+ </Flex>
356
+ ))
357
+ )}
358
+ <ChatComposer
359
+ aria-label="課題へのコメント"
360
+ submitType="modEnter"
361
+ allowEmptySubmit
362
+ value={commentDraft}
363
+ onValueChange={setCommentDraft}
364
+ onSubmit={postComment}
365
+ placeholder="コメントを入力"
366
+ submitLabel="コメントを投稿"
367
+ footer={
368
+ <Flex direction="row" gap="sm" align="center" justify="between" wrap>
369
+ <Select
370
+ size="sm"
371
+ aria-label="ステータス"
372
+ value={status}
373
+ onValueChange={setStatus}
374
+ clearable={false}
375
+ options={STATUSES}
376
+ />
377
+ <Text size="xs" tone="muted">
378
+ {t("dataEntry.chatComposer.hintModEnter", {
379
+ modifier: isApplePlatform() ? "⌘" : "Ctrl",
380
+ })}
381
+ </Text>
382
+ </Flex>
383
+ }
384
+ />
385
+ </Flex>
386
+ </CardContent>
387
+ </Card>
388
+
289
389
  {/* ── 3. Every size step ───────────────────────────────────────────────────────── */}
290
390
  <Card>
291
391
  <CardHeader>
@@ -4,15 +4,19 @@ import { QueryClient, QueryClientProvider, useMutation } from "@tanstack/react-q
4
4
 
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@godxjp/ui/data-display";
6
6
  import { FormField, Input } from "@godxjp/ui/data-entry";
7
+ import { FormFieldControl, FormRoot, useZodForm } from "@godxjp/ui/form";
7
8
  import { Button } from "@godxjp/ui/general";
8
9
  import { Flex, PageContainer } from "@godxjp/ui/layout";
9
10
  import { AlertMutationFeedback } from "@godxjp/ui/query";
11
+ import { z } from "zod";
10
12
 
11
13
  /**
12
14
  * AlertMutationFeedback · inline mutation error below a form's submit. Renders
13
15
  * NOTHING while idle/successful; surfaces the error + a retry when useMutation
14
16
  * fails. Composed from real @godxjp/ui + @tanstack/react-query. (This demo fires
15
- * the mutation once on mount so the error state is visible.)
17
+ * the mutation once on mount so the error state is visible.) Inside a FormRoot
18
+ * that received `errors`, a 400/422 validation error is skipped by default —
19
+ * the fields already show the bag — while a 5xx still renders the alert.
16
20
  */
17
21
  const queryClient = new QueryClient();
18
22
 
@@ -54,6 +58,59 @@ function Block() {
54
58
  );
55
59
  }
56
60
 
61
+ type SaveError = Error & { status: number; errors?: Record<string, string[]> };
62
+
63
+ function saveError(status: number, message: string, errors?: Record<string, string[]>) {
64
+ return Object.assign(new Error(message), { status, errors }) as SaveError;
65
+ }
66
+
67
+ const schema = z.object({ code: z.string() });
68
+
69
+ function InFormBlock() {
70
+ const form = useZodForm(schema, { defaultValues: { code: "BTY-0012" } });
71
+ const mutation = useMutation<void, SaveError, number>({
72
+ mutationFn: async (status) => {
73
+ throw status === 422
74
+ ? saveError(422, "The given data was invalid.", {
75
+ code: ["取引先コードが重複しています"],
76
+ })
77
+ : saveError(500, "サーバーエラーが発生しました (500)");
78
+ },
79
+ });
80
+
81
+ useEffect(() => {
82
+ mutation.mutate(422);
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, []);
85
+
86
+ return (
87
+ <Card>
88
+ <CardHeader>
89
+ <CardTitle level={2}>取引先の登録 · FormRoot errors</CardTitle>
90
+ <CardDescription>
91
+ A 422 shows on the field only; a 500 still renders the alert.
92
+ </CardDescription>
93
+ </CardHeader>
94
+ <CardContent>
95
+ <FormRoot form={form} errors={mutation.error?.errors} onSubmit={() => mutation.mutate(422)}>
96
+ <FormFieldControl name="code" label="取引先コード">
97
+ {(field) => <Input {...field} value={String(field.value ?? "")} />}
98
+ </FormFieldControl>
99
+ <AlertMutationFeedback mutation={mutation} onRetry={() => mutation.mutate(500)} />
100
+ <Flex direction="row" wrap justify="end" gap="sm">
101
+ <Button variant="outline" type="button" onClick={() => mutation.mutate(500)}>
102
+ 500 を再現
103
+ </Button>
104
+ <Button type="submit" loading={mutation.isPending}>
105
+ 保存
106
+ </Button>
107
+ </Flex>
108
+ </FormRoot>
109
+ </CardContent>
110
+ </Card>
111
+ );
112
+ }
113
+
57
114
  export default function Demo() {
58
115
  return (
59
116
  <QueryClientProvider client={queryClient}>
@@ -63,6 +120,7 @@ export default function Demo() {
63
120
  >
64
121
  <Flex direction="col" gap="lg">
65
122
  <Block />
123
+ <InFormBlock />
66
124
  </Flex>
67
125
  </PageContainer>
68
126
  </QueryClientProvider>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "26.2.0",
4
- "godxUiMcp": "26.2.0",
3
+ "version": "26.4.0",
4
+ "godxUiMcp": "26.4.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -58,12 +58,19 @@ function isPackageWrittenMcpEntry(entry) {
58
58
  return Object.keys(entry.env ?? {}).every((key) => key === "GODX_UI_VERSION");
59
59
  }
60
60
 
61
- function mcpConfigMismatchMessage(root, existing, expected) {
61
+ /** True when an `mcpServers` entry launches the catalog server, under any key or launcher flags. */
62
+ function runsUiMcp(entry) {
63
+ const parts = [entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])];
64
+ return parts.some((part) => typeof part === "string" && /(^|\/)@godxjp\/ui-mcp(@|$)/.test(part));
65
+ }
66
+
67
+ function mcpConfigMismatchMessage(root, existing, expected, key = MCP_KEY) {
62
68
  const ui = readConsumerUiMetadata(root);
63
69
  const pin = expected.args?.[0] ?? "@godxjp/ui-mcp";
64
70
  const ver = ui?.version ?? "(unknown)";
65
71
  return (
66
- `present (custom godx-ui MCP entry — not overwritten; expected ${pin} with ` +
72
+ `present (custom godx-ui MCP entry${key === MCP_KEY ? "" : ` under key "${key}"`} — not overwritten, ` +
73
+ `no duplicate added; expected ${pin} with ` +
67
74
  `env.GODX_UI_VERSION=${ver} from node_modules/@godxjp/ui)`
68
75
  );
69
76
  }
@@ -265,7 +272,18 @@ function readJsonFile(path) {
265
272
  // writing the result back is the same data loss by a different door, so it is a refusal too.
266
273
  if (json === null || typeof json !== "object" || Array.isArray(json))
267
274
  return { state: "wrong-shape" };
268
- return { state: "ok", json };
275
+ return { state: "ok", json, indent: indentOf(raw) };
276
+ }
277
+
278
+ /**
279
+ * The indentation the file already uses — a tab, or the width of the first indented line — so a
280
+ * rewrite changes only the keys it touches. Re-serialising at a fixed 2 turned a one-entry edit
281
+ * into a whole-file diff in a repo formatted at 4 (gh#692).
282
+ */
283
+ function indentOf(raw) {
284
+ const first = raw.match(/\n([ \t]+)\S/);
285
+ if (!first) return 2;
286
+ return first[1].startsWith("\t") ? "\t" : first[1].length;
269
287
  }
270
288
 
271
289
  /** The sentence that goes in the refusal, so a consumer knows which of the three it hit. */
@@ -385,18 +403,25 @@ export function ensureMcpJson(root) {
385
403
  return refuseAndSuggest(path, suggested, "`mcpServers` is not an object");
386
404
  }
387
405
  json.mcpServers = json.mcpServers ?? {};
388
- if (json.mcpServers[MCP_KEY]) {
389
- if (mcpEntryMatches(json.mcpServers[MCP_KEY], expected)) return "present";
390
- if (!isPackageWrittenMcpEntry(json.mcpServers[MCP_KEY])) {
391
- return mcpConfigMismatchMessage(root, json.mcpServers[MCP_KEY], expected);
406
+ const indent = read.state === "ok" ? read.indent : 2;
407
+ // The catalog server may already be registered under ANOTHER key — `godxjp-ui`, `ui`, whatever
408
+ // the repo chose. Looking only at MCP_KEY added a second copy of the same server beside it, at
409
+ // a different version (gh#692). Whatever key runs @godxjp/ui-mcp is the entry.
410
+ const key = json.mcpServers[MCP_KEY]
411
+ ? MCP_KEY
412
+ : Object.keys(json.mcpServers).find((name) => runsUiMcp(json.mcpServers[name]));
413
+ if (key) {
414
+ if (mcpEntryMatches(json.mcpServers[key], expected)) return "present";
415
+ if (!isPackageWrittenMcpEntry(json.mcpServers[key])) {
416
+ return mcpConfigMismatchMessage(root, json.mcpServers[key], expected, key);
392
417
  }
393
- json.mcpServers[MCP_KEY] = expected;
394
- writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
418
+ json.mcpServers[key] = expected;
419
+ writeFileAtomic(path, JSON.stringify(json, null, indent) + "\n");
395
420
  return "refreshed";
396
421
  }
397
422
  const created = read.state === "missing";
398
423
  json.mcpServers[MCP_KEY] = expected;
399
- writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
424
+ writeFileAtomic(path, JSON.stringify(json, null, indent) + "\n");
400
425
  return created ? "created" : "added";
401
426
  }
402
427
 
@@ -450,7 +475,7 @@ export function ensureClaudeHooks(root) {
450
475
  added.push("SessionStart:workflow-primer");
451
476
  }
452
477
 
453
- writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
478
+ writeFileAtomic(path, JSON.stringify(json, null, read.state === "ok" ? read.indent : 2) + "\n");
454
479
  return added;
455
480
  }
456
481