@godxjp/ui 30.9.0 → 31.0.2

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 (52) hide show
  1. package/README.md +2 -1
  2. package/agent/START-HERE.md +1 -1
  3. package/agent/components/PrefetchLink.json +3 -3
  4. package/agent/components-index.json +1 -1
  5. package/agent/components.json +3 -3
  6. package/agent/index.json +2 -2
  7. package/agent/llms.txt +3 -3
  8. package/agent/patterns/settings-page-responsive.json +1 -1
  9. package/agent/patterns.json +1 -1
  10. package/dist/components/query/index.d.ts +0 -2
  11. package/dist/components/query/index.js +0 -2
  12. package/dist/components/react-router/index.d.ts +2 -0
  13. package/dist/components/react-router/index.js +4 -0
  14. package/dist/contracts/measurement.json +1 -1
  15. package/dist/styles/alert-layout.css +4 -2
  16. package/dist/styles/layers.json +1 -1
  17. package/docs/CONSUMER-RULES.md +1 -1
  18. package/docs/data-display/permission-matrix.tsx +30 -28
  19. package/docs/data-entry/cascader.tsx +22 -20
  20. package/docs/data-entry/date-picker.tsx +47 -43
  21. package/docs/data-entry/form-field/examples/a11y-contract.tsx +80 -76
  22. package/docs/data-entry/form-field/examples/create-form.tsx +33 -31
  23. package/docs/data-entry/form-field/index.tsx +61 -57
  24. package/docs/data-entry/form.tsx +112 -106
  25. package/docs/data-entry/input.tsx +297 -270
  26. package/docs/data-entry/label.tsx +11 -9
  27. package/docs/data-entry/number-input.tsx +86 -78
  28. package/docs/data-entry/radio-group.tsx +33 -26
  29. package/docs/data-entry/rating.tsx +29 -25
  30. package/docs/data-entry/select-matrix.tsx +49 -47
  31. package/docs/data-entry/select.tsx +38 -35
  32. package/docs/data-entry/slider.tsx +45 -41
  33. package/docs/data-entry/textarea.tsx +44 -42
  34. package/docs/data-entry/time-picker.tsx +51 -47
  35. package/docs/feedback/sheet.tsx +84 -79
  36. package/docs/foundation/theme-editor.tsx +133 -124
  37. package/docs/general/button/examples/form-actions.tsx +17 -15
  38. package/docs/layout/auth-recovery/examples/password-recovery.tsx +31 -29
  39. package/docs/layout/auth-shell-registration.tsx +75 -72
  40. package/docs/layout/auth-shell-variants.tsx +55 -51
  41. package/docs/layout/space-compact.tsx +11 -8
  42. package/docs/{query → react-router}/prefetch-link.tsx +1 -1
  43. package/docs/showcase/caimono-price-comparison.tsx +59 -51
  44. package/docs/showcase/case3-approval-workflow.tsx +27 -25
  45. package/docs/showcase/case4-login.tsx +37 -34
  46. package/docs/showcase/permission-matrix.tsx +44 -42
  47. package/docs/showcase/theme-customization.tsx +108 -99
  48. package/package.json +6 -2
  49. package/scripts/consumer-rule.md +14 -0
  50. package/scripts/ui-audit.mjs +99 -2
  51. /package/dist/components/{query → react-router}/prefetch-link.d.ts +0 -0
  52. /package/dist/components/{query → react-router}/prefetch-link.js +0 -0
@@ -136,6 +136,7 @@ import {
136
136
  type TimelineItem,
137
137
  } from "@godxjp/ui/data-display";
138
138
  import {
139
+ Form,
139
140
  CheckboxGroup,
140
141
  ColorPicker,
141
142
  DatePicker,
@@ -586,85 +587,91 @@ function ComponentsBoard(props: {
586
587
  pad={{ inline: "lg", block: "md" }}
587
588
  >
588
589
  {/* 1 · text entry */}
589
- <Flex direction="col" gap="md">
590
- <FormField
591
- id="board-email"
592
- label={t("themeShowcase.form.email")}
593
- helper={t("themeShowcase.form.emailHint")}
594
- >
595
- <Input
590
+ <Form>
591
+ <Flex direction="col" gap="md">
592
+ <FormField
596
593
  id="board-email"
597
- type="email"
598
- autoComplete="email"
599
- prefix={<Mail aria-hidden="true" />}
600
- placeholder={t("themeShowcase.form.emailPlaceholder")}
601
- defaultValue="release-desk@ops.example.jp"
602
- />
603
- </FormField>
604
- <FormField id="board-handle" label={t("themeShowcase.form.handle")}>
605
- <Input
606
- id="board-handle"
607
- addonBefore={<AtSign aria-hidden="true" />}
608
- defaultValue="release-desk"
609
- count={{ max: 24, show: true }}
610
- />
611
- </FormField>
612
- </Flex>
594
+ label={t("themeShowcase.form.email")}
595
+ helper={t("themeShowcase.form.emailHint")}
596
+ >
597
+ <Input
598
+ id="board-email"
599
+ type="email"
600
+ autoComplete="email"
601
+ prefix={<Mail aria-hidden="true" />}
602
+ placeholder={t("themeShowcase.form.emailPlaceholder")}
603
+ defaultValue="release-desk@ops.example.jp"
604
+ />
605
+ </FormField>
606
+ <FormField id="board-handle" label={t("themeShowcase.form.handle")}>
607
+ <Input
608
+ id="board-handle"
609
+ addonBefore={<AtSign aria-hidden="true" />}
610
+ defaultValue="release-desk"
611
+ count={{ max: 24, show: true }}
612
+ />
613
+ </FormField>
614
+ </Flex>
615
+ </Form>
613
616
 
614
617
  {/* 2 · multi-select with removable tags + free tags */}
615
- <Flex direction="col" gap="md">
616
- <FormField
617
- id="board-regions"
618
- label={t("themeShowcase.form.regions")}
619
- helper={regions.length > 0 ? regionList : t("themeShowcase.form.regionsEmpty")}
620
- >
621
- <Select
618
+ <Form>
619
+ <Flex direction="col" gap="md">
620
+ <FormField
622
621
  id="board-regions"
623
- mode="multiple"
624
- maxTagCount={2}
625
- value={regions}
626
- onValueChange={setRegions}
627
- placeholder={t("themeShowcase.form.regionsPlaceholder")}
628
- options={REGION_CODES.map((code) => ({
629
- value: code,
630
- label: new Intl.DisplayNames([props.locale], { type: "region" }).of(code) ?? code,
631
- }))}
632
- />
633
- </FormField>
634
- <FormField id="board-labels" label={t("themeShowcase.form.labels")}>
635
- <TagInput
636
- id="board-labels"
637
- value={labels}
638
- onValueChange={setLabels}
639
- maxTagCount={2}
640
- /* The ceiling the 71-character id is measured against (gh#840). 16 is the widest cut
622
+ label={t("themeShowcase.form.regions")}
623
+ helper={regions.length > 0 ? regionList : t("themeShowcase.form.regionsEmpty")}
624
+ >
625
+ <Select
626
+ id="board-regions"
627
+ mode="multiple"
628
+ maxTagCount={2}
629
+ value={regions}
630
+ onValueChange={setRegions}
631
+ placeholder={t("themeShowcase.form.regionsPlaceholder")}
632
+ options={REGION_CODES.map((code) => ({
633
+ value: code,
634
+ label: new Intl.DisplayNames([props.locale], { type: "region" }).of(code) ?? code,
635
+ }))}
636
+ />
637
+ </FormField>
638
+ <FormField id="board-labels" label={t("themeShowcase.form.labels")}>
639
+ <TagInput
640
+ id="board-labels"
641
+ value={labels}
642
+ onValueChange={setLabels}
643
+ maxTagCount={2}
644
+ /* The ceiling the 71-character id is measured against (gh#840). 16 is the widest cut
641
645
  that still leaves room for a second chip and the `+1` on one row at 375px; the value
642
646
  is untouched — it stays in the chip's `title` and in the remover's accessible name,
643
647
  which is the only reason truncating an identifier is allowed at all. */
644
- maxTagTextLength={16}
645
- placeholder={t("themeShowcase.form.labelsPlaceholder")}
646
- />
647
- </FormField>
648
- </Flex>
648
+ maxTagTextLength={16}
649
+ placeholder={t("themeShowcase.form.labelsPlaceholder")}
650
+ />
651
+ </FormField>
652
+ </Flex>
653
+ </Form>
649
654
 
650
655
  {/* 3 · dropdown + date */}
651
- <Flex direction="col" gap="md">
652
- <FormField id="board-plan" label={t("themeShowcase.form.plan")}>
653
- <Select
654
- id="board-plan"
655
- value={plan}
656
- onValueChange={setPlan}
657
- options={[
658
- { value: "starter", label: t("themeShowcase.plan.starter") },
659
- { value: "standard", label: t("themeShowcase.plan.standard") },
660
- { value: "scale", label: t("themeShowcase.plan.scale") },
661
- ]}
662
- />
663
- </FormField>
664
- <FormField id="board-date" label={t("themeShowcase.form.releaseDate")}>
665
- <DatePicker id="board-date" defaultValue={new Date("2026-09-12T00:00:00Z")} />
666
- </FormField>
667
- </Flex>
656
+ <Form>
657
+ <Flex direction="col" gap="md">
658
+ <FormField id="board-plan" label={t("themeShowcase.form.plan")}>
659
+ <Select
660
+ id="board-plan"
661
+ value={plan}
662
+ onValueChange={setPlan}
663
+ options={[
664
+ { value: "starter", label: t("themeShowcase.plan.starter") },
665
+ { value: "standard", label: t("themeShowcase.plan.standard") },
666
+ { value: "scale", label: t("themeShowcase.plan.scale") },
667
+ ]}
668
+ />
669
+ </FormField>
670
+ <FormField id="board-date" label={t("themeShowcase.form.releaseDate")}>
671
+ <DatePicker id="board-date" defaultValue={new Date("2026-09-12T00:00:00Z")} />
672
+ </FormField>
673
+ </Flex>
674
+ </Form>
668
675
 
669
676
  {/* 4 · checkboxes — a three-line label beside a one-word one, and a disabled row */}
670
677
  <Flex direction="col" gap="md">
@@ -929,35 +936,37 @@ function ComponentsBoard(props: {
929
936
  <CardDescription>{t("themeShowcase.signup.description")}</CardDescription>
930
937
  </CardHeader>
931
938
  <CardContent>
932
- <Flex direction="col" gap="md">
933
- <FormField id="signup-email" label={t("themeShowcase.signup.email")}>
934
- <Input
935
- id="signup-email"
936
- type="email"
937
- autoComplete="email"
938
- placeholder="you@example.jp"
939
- />
940
- </FormField>
941
- <FormField id="signup-secret" label={t("themeShowcase.signup.password")}>
942
- <PasswordInput
943
- id="signup-secret"
944
- value={secret}
945
- onChange={(event) => setSecret(event.target.value)}
946
- autoComplete="new-password"
947
- />
948
- </FormField>
949
- <PasswordStrength value={secret} />
950
- <Button fullWidth>{t("themeShowcase.signup.submit")}</Button>
951
- <AuthDivider label={t("themeShowcase.signup.or")} />
952
- <Button variant="outline" fullWidth>
953
- <ShieldCheck aria-hidden="true" />
954
- {t("themeShowcase.signup.sso")}
955
- </Button>
956
- <Button variant="outline" fullWidth>
957
- <Fingerprint aria-hidden="true" />
958
- {t("themeShowcase.signup.passkey")}
959
- </Button>
960
- </Flex>
939
+ <Form>
940
+ <Flex direction="col" gap="md">
941
+ <FormField id="signup-email" label={t("themeShowcase.signup.email")}>
942
+ <Input
943
+ id="signup-email"
944
+ type="email"
945
+ autoComplete="email"
946
+ placeholder="you@example.jp"
947
+ />
948
+ </FormField>
949
+ <FormField id="signup-secret" label={t("themeShowcase.signup.password")}>
950
+ <PasswordInput
951
+ id="signup-secret"
952
+ value={secret}
953
+ onChange={(event) => setSecret(event.target.value)}
954
+ autoComplete="new-password"
955
+ />
956
+ </FormField>
957
+ <PasswordStrength value={secret} />
958
+ <Button fullWidth>{t("themeShowcase.signup.submit")}</Button>
959
+ <AuthDivider label={t("themeShowcase.signup.or")} />
960
+ <Button variant="outline" fullWidth>
961
+ <ShieldCheck aria-hidden="true" />
962
+ {t("themeShowcase.signup.sso")}
963
+ </Button>
964
+ <Button variant="outline" fullWidth>
965
+ <Fingerprint aria-hidden="true" />
966
+ {t("themeShowcase.signup.passkey")}
967
+ </Button>
968
+ </Flex>
969
+ </Form>
961
970
  </CardContent>
962
971
  </Card>
963
972
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "30.9.0",
4
- "godxUiMcp": "30.9.0",
3
+ "version": "31.0.2",
4
+ "godxUiMcp": "31.0.2",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -141,6 +141,10 @@
141
141
  "types": "./dist/inertia/index.d.ts",
142
142
  "import": "./dist/inertia/index.js"
143
143
  },
144
+ "./react-router": {
145
+ "types": "./dist/components/react-router/index.d.ts",
146
+ "import": "./dist/components/react-router/index.js"
147
+ },
144
148
  "./app": {
145
149
  "types": "./dist/app/index.d.ts",
146
150
  "import": "./dist/app/index.js"
@@ -120,6 +120,20 @@ ngày: năm thứ cần đều ĐÃ CÓ và vẫn bị dựng lại bằng thứ
120
120
  Lỗi không phải "đoán sai tên prop" mà là **cho rằng nó không tồn tại nên không
121
121
  hỏi**.
122
122
 
123
+ ## Form: `Form` bọc `FormField`, bề rộng theo nội dung, form dài là PAGE
124
+
125
+ godx-mailer dính bốn lỗi form trong một ngày, cả bốn đều là kit ĐÃ CÓ đồ đúng
126
+ (gh#998). Giờ ui-audit chặn:
127
+
128
+ | Sai | Đúng | rule |
129
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ---------------------- |
130
+ | ≥2 `FormField` không có `Form`, hoặc xếp hàng bằng `<Flex>` | `<Form layout="horizontal" labelWidth controlWidth>`; hàng = `SpaceCompact` / `Form columns` | `formfield-needs-form` |
131
+ | ≥3 `FormField` trong `DialogBody` | page riêng (route riêng) | `dialog-form-too-big` |
132
+ | `Select` hai lựa chọn kéo full width | `controlWidth` trên field hoặc một lần trên `Form` | `select-width-hint` |
133
+
134
+ Ca kit không diễn đạt được thì **mở issue ở godx-jp/godxjp-ui**, tạm dùng cách
135
+ hợp lệ gần nhất kèm `// TODO(godxjp-ui#<n>)` — không tự chế.
136
+
123
137
  ## Dialog và AlertDialog là MỘT họ — `variant` là lối chuẩn
124
138
 
125
139
  Đừng với tay sang 12 export `AlertDialog*` nữa. Chúng **vẫn chạy y như cũ** (gỡ
@@ -85,8 +85,6 @@ function changedFiles() {
85
85
  };
86
86
  }
87
87
 
88
-
89
-
90
88
  const parts = [
91
89
  run(["diff", "--name-only", "--diff-filter=ACMR", mergeBase.trim(), "--"]),
92
90
  run(["diff", "--name-only", "--diff-filter=ACMR", "--cached"]),
@@ -1895,6 +1893,105 @@ for (const dir of SCAN_DIRS) {
1895
1893
  snippet: match[0].replace(/\s+/g, " ").slice(0, 120),
1896
1894
  });
1897
1895
  }
1896
+
1897
+ /*
1898
+ * THE LAYER ABOVE THE FIELD (gh#998). `bare-control-needs-formfield` stops a control without a
1899
+ * FormField; nothing stopped a FormField without a Form, or a Form without a width, or a form
1900
+ * that belongs on a page stuffed into a Dialog. godx-mailer hit all four in one day. Validated
1901
+ * on the reporter's own files before and after its fixes: every defect caught before, none in
1902
+ * the fixed files except one the fix missed (four FormFields in a hand-rolled wrapping Flex).
1903
+ */
1904
+ if (isJsx) {
1905
+ const lineAt = (i) => scanContent.slice(0, i).split("\n").length;
1906
+ /*
1907
+ * TWO OR MORE FormFields under ONE parent with no <Form> above them. That is the shape of
1908
+ * every defect in the report — a hand-rolled <Flex> row of fields, a long form in a Dialog, a
1909
+ * page of fields each choosing its own layout. A LONE field is not: a search box, the
1910
+ * ConfirmDialog's type-to-confirm input, a field component whose whole output is one
1911
+ * FormField. Flagging those made this repo's own library code and 29 docs pages red on a
1912
+ * rule whose defect they do not have.
1913
+ *
1914
+ * The walk keeps a stack of open JSX elements so "same parent" is structural. `(?<![\w.$])`
1915
+ * skips a generic argument (`useRef<HTMLDivElement>`), which is preceded by an identifier.
1916
+ */
1917
+ const stack = [];
1918
+ const bareByParent = new Map();
1919
+ let formDepth = 0;
1920
+ for (const m of scanContent.matchAll(
1921
+ /(?<![\w.$])<(\/?)([A-Za-z][\w.]*)(?=[\s>/])([^<>]*?(?:\{[^{}]*\}[^<>]*?)*)(\/?)>/g,
1922
+ )) {
1923
+ const [, close, tag, , selfClose] = m;
1924
+ if (close) {
1925
+ for (let i = stack.length - 1; i >= 0; i -= 1) {
1926
+ if (stack[i].tag === tag) {
1927
+ stack.length = i;
1928
+ break;
1929
+ }
1930
+ }
1931
+ if (tag === "Form") formDepth = Math.max(0, formDepth - 1);
1932
+ continue;
1933
+ }
1934
+ if (tag === "FormField" && formDepth === 0) {
1935
+ const parent = stack.at(-1)?.index ?? -1;
1936
+ (bareByParent.get(parent) ?? bareByParent.set(parent, []).get(parent)).push(m.index);
1937
+ }
1938
+ if (selfClose) continue;
1939
+ stack.push({ tag, index: m.index });
1940
+ if (tag === "Form") formDepth += 1;
1941
+ }
1942
+ for (const group of bareByParent.values()) {
1943
+ if (group.length < 2) continue;
1944
+ for (const at of group) {
1945
+ const lineNo = lineAt(at);
1946
+ if (suppressed("formfield-needs-form", lineNo - 1)) continue;
1947
+ findings.push({
1948
+ file: rel,
1949
+ line: lineNo,
1950
+ rule: "formfield-needs-form",
1951
+ severity: "error",
1952
+ standard: "@godxjp/ui Form (gh#998)",
1953
+ message: `${group.length} FormFields under one parent with no <Form> around them. <Form layout="horizontal" labelWidth controlWidth> is what lines fields up — without it each form picks its own layout, and a row of fields in a hand-rolled <Flex> drops the required field's label out of line. Wrap them in <Form>; a row that belongs together is <SpaceCompact> or <Form columns>. A lone field (a search box, a type-to-confirm input) is not flagged.`,
1954
+ snippet: scanContent.slice(at, at + 120).replace(/\s+/g, " "),
1955
+ });
1956
+ }
1957
+ }
1958
+
1959
+ for (const m of scanContent.matchAll(/<(Dialog\.Body|DialogBody)(?=[\s>])[\s\S]*?<\/\1>/g)) {
1960
+ const count = (m[0].match(/<FormField(?=[\s>/])/g) ?? []).length;
1961
+ if (count < 3) continue;
1962
+ const lineNo = lineAt(m.index);
1963
+ if (suppressed("dialog-form-too-big", lineNo - 1)) continue;
1964
+ findings.push({
1965
+ file: rel,
1966
+ line: lineNo,
1967
+ rule: "dialog-form-too-big",
1968
+ severity: "error",
1969
+ standard: "@godxjp/ui form placement (gh#998)",
1970
+ message: `${count} FormFields in a ${m[1]} — a form this size is a page of its own (its own route, back button, and room to scroll), not a modal. Keep dialogs for a confirmation or one or two fields. A Sheet is not flagged: a side drawer is where an advanced filter or an edit form belongs (antd Drawer).`,
1971
+ snippet: m[0].slice(0, 120).replace(/\s+/g, " "),
1972
+ });
1973
+ }
1974
+
1975
+ for (const m of scanContent.matchAll(/<FormField(?=[\s>])([^>]*)>([\s\S]*?)<\/FormField>/g)) {
1976
+ if (!/<Select(?=[\s>/])/.test(m[2]) || /controlWidth=/.test(m[1])) continue;
1977
+ const before = scanContent.slice(0, m.index);
1978
+ const lastForm = [...before.matchAll(/<Form(?=[\s>])([^>]*)>/g)].at(-1);
1979
+ const insideForm = lastForm && before.lastIndexOf("</Form>") < lastForm.index;
1980
+ if (insideForm && /controlWidth=/.test(lastForm[1])) continue;
1981
+ const lineNo = lineAt(m.index);
1982
+ if (suppressed("select-width-hint", lineNo - 1)) continue;
1983
+ findings.push({
1984
+ file: rel,
1985
+ line: lineNo,
1986
+ rule: "select-width-hint",
1987
+ severity: "warn",
1988
+ standard: "GOV.UK Design System · text input width",
1989
+ message:
1990
+ "A <Select> in a FormField with no controlWidth on the field or its <Form> stretches to the full column — a two-option select as wide as an address line. Size the control for its content (`controlWidth` on the FormField, or once on the Form). https://design-system.service.gov.uk/components/text-input/",
1991
+ snippet: scanContent.slice(m.index, m.index + 120).replace(/\s+/g, " "),
1992
+ });
1993
+ }
1994
+ }
1898
1995
  }
1899
1996
  }
1900
1997