@godxjp/ui 33.1.1 → 33.3.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.
Files changed (56) hide show
  1. package/README.md +5 -5
  2. package/agent/START-HERE.md +7 -7
  3. package/agent/components/Command.json +9 -1
  4. package/agent/components/ResponsiveGrid.json +2 -2
  5. package/agent/components/SearchForm.json +115 -0
  6. package/agent/components/Toolbar.json +6 -3
  7. package/agent/components/TranslatableInput.json +92 -0
  8. package/agent/components/TranslatableTextarea.json +84 -0
  9. package/agent/components-index.json +20 -1
  10. package/agent/components.json +308 -6
  11. package/agent/index.json +8 -8
  12. package/agent/llms.txt +8 -8
  13. package/agent/patterns/condition-builder.json +31 -0
  14. package/agent/patterns/data-table-page.json +1 -1
  15. package/agent/patterns/filter-bar-layouts.json +30 -0
  16. package/agent/patterns/inertia-list-page.json +1 -1
  17. package/agent/patterns/list-filters.json +1 -1
  18. package/agent/patterns/search-form.json +27 -0
  19. package/agent/patterns-index.json +85 -0
  20. package/agent/patterns.json +91 -3
  21. package/agent/rules.json +1 -1
  22. package/agent/tokens.json +12 -0
  23. package/dist/components/data-entry/command.d.ts +22 -1
  24. package/dist/components/data-entry/command.js +14 -1
  25. package/dist/components/data-entry/form-field.js +10 -4
  26. package/dist/components/data-entry/index.d.ts +4 -2
  27. package/dist/components/data-entry/index.js +6 -1
  28. package/dist/components/data-entry/translatable-input.d.ts +29 -0
  29. package/dist/components/data-entry/translatable-input.js +184 -0
  30. package/dist/components/navigation/index.d.ts +2 -0
  31. package/dist/components/navigation/index.js +2 -0
  32. package/dist/components/navigation/search-form.d.ts +31 -0
  33. package/dist/components/navigation/search-form.js +112 -0
  34. package/dist/contracts/measurement.json +1 -1
  35. package/dist/i18n/messages/en.json +13 -0
  36. package/dist/i18n/messages/ja.json +13 -0
  37. package/dist/i18n/messages/vi.json +13 -0
  38. package/dist/lib/field-a11y.d.ts +15 -0
  39. package/dist/lib/field-a11y.js +13 -1
  40. package/dist/lib/intl-cache.d.ts +1 -0
  41. package/dist/lib/intl-cache.js +4 -0
  42. package/dist/props/components/data-entry.prop.d.ts +46 -0
  43. package/dist/props/components/index.d.ts +1 -1
  44. package/dist/props/components/layout.prop.d.ts +3 -1
  45. package/dist/props/components/navigation.prop.d.ts +43 -3
  46. package/dist/props/registry.d.ts +56 -0
  47. package/dist/props/registry.js +91 -0
  48. package/dist/styles/control.css +15 -0
  49. package/dist/styles/data-entry-layout.css +25 -0
  50. package/dist/styles/layers.json +29 -1
  51. package/dist/styles/layout.css +41 -0
  52. package/dist/styles.css +1 -1
  53. package/dist/tokens/components/data-entry.css +4 -0
  54. package/docs/CONSUMER-RULES.md +1 -1
  55. package/package.json +2 -2
  56. package/scripts/ui-audit.mjs +148 -3
@@ -25,4 +25,8 @@
25
25
  --branch-scope-picker-subset-border-width: initial;
26
26
 
27
27
  --branch-scope-picker-list-max-height: 16rem;
28
+
29
+ --translatable-field-gap: var(--space-1);
30
+
31
+ --translatable-field-error-icon-size: var(--icon-size-xs);
28
32
  }
@@ -5,7 +5,7 @@ should look like) is in the package's `DESIGN.md`. Everything else in `docs/` is
5
5
 
6
6
  1. Load styles with `import "@godxjp/ui/styles.css"` (precompiled, no Tailwind needed) or `@import "@godxjp/ui/styles"` (Tailwind v4). Neither ships font files: fonts are opt-in since v32 (`pnpm add @fontsource/noto-sans-jp @fontsource/m-plus-2`, then `@import "@godxjp/ui/styles/fonts.css"` after the styles import; the GoDX preset does this for you). Never cherry-pick `*-layout.css`.
7
7
  2. **Mount `<AppProvider defaultLocale="…">` once, at the root.** Without it the kit's OWN strings (dialog close/cancel/delete, counters, picker labels) fall to the resting locale (`<html lang>` when it names a registered locale, else `en`) and pickers to UTC (gh#1005). A development build now warns once when a kit component renders outside it; `syncI18nLocale(locale, fallback)` is the escape for an app that truly cannot mount the provider. Every page is `<PageContainer title extra footer>` (a `subtitle` only when it adds a fact the title does not); its sections are spaced by the page. Group items inside a section with `<Flex direction="col" gap>` or `<ResponsiveGrid>`.
8
- 3. No Tailwind layout on your own elements: no `flex`, `grid`, `gap-*`, `p-*`, `m-*`, `space-*`. Rows are `<Flex>` (default row), stacks are `<Flex direction="col">`, grids are `<ResponsiveGrid>`. A list's filters are ONE `<FilterBar>` that wraps at every width — never a sideways-scrolling strip of filters or filter chips; past 2–4 filters the rest go behind one "More filters" `Sheet` (cardinal rule #51, pattern `list-filters`, audit rules `filter-bar-no-overflow` and `filter-strip-no-scroll`).
8
+ 3. No Tailwind layout on your own elements: no `flex`, `grid`, `gap-*`, `p-*`, `m-*`, `space-*`. Rows are `<Flex>` (default row), stacks are `<Flex direction="col">`, grids are `<ResponsiveGrid>`. A list's filters are ONE `<FilterBar>` that wraps at every width — never a sideways-scrolling strip of filters or filter chips; past 2–4 filters the rest go behind one "More filters" `Sheet` (cardinal rule #51, pattern `list-filters`, audit rules `filter-bar-no-overflow`, `filter-strip-no-scroll` and `hand-rolled-filter-bar`). Filters are packed at the start: never a hand-built `<Flex justify="between">` of FormFields above a table. Many conditions or an explicit 検索 button → `SearchForm` (pattern `search-form`); field × operator rows saved as views → pattern `condition-builder`.
9
9
  4. No hand-rolled surfaces: no `rounded-* border bg-*` divs. A box is `Card`, a pill is `Badge`, a person is `Avatar`, a row is `ListRow`, a label/value pair is `Descriptions`, an empty area is `EmptyState`, a read-only sample of a colour the USER chose is `Swatch`. A LIST of those rows is `<Flex as="ul" marker="none" direction="col" gap="none">` with `<ListRow as="li">` children — `marker="none"` keeps the `<ul>`, the `<li>` semantics and the gap token while dropping the bullet and the indent. Never a raw `<ul>`/`<ol>` (it carries no gap token), never `<div role="list">` + `<div role="listitem">`, and never a wrapper around each row: the divider is `:not(:last-child)` among siblings, so a row alone in its own wrapper is always the last one and EVERY divider disappears silently. A bulleted prose list is the same `<Flex as="ul">` without `marker`.
10
10
  5. Real controls only: `Button`, `Input`, `Select`, `Textarea`, `Checkbox`… never raw `<button>`/`<input>`; a labelled control lives in `<FormField label>`. A Select outside a form takes `width="auto"`. **A disabled control's reason is visible text, never a tooltip** — see below. **Fields live in a `<Form layout="horizontal" labelWidth controlWidth>`** — never a group of `FormField`s without one, never a hand-rolled `<Flex>` row of fields (a row that belongs together is `<Flex attached>` or `<Form columns>`); size each control for its content with `controlWidth` (GOV.UK text-input width: a port ~7rem, a short enum ~10rem); three or more fields is a page, not a Dialog. The audit rules are `formfield-needs-form`, `dialog-form-too-big` and `select-width-hint`. **A case the kit cannot express is an issue, not a hand-roll:** open it on godx-jp/godxjp-ui, use the nearest valid composition, and mark it `// TODO(godxjp-ui#<n>)` (gh#998). **One action row, one Button size:** every `Button` under one parent takes the same `size` — a missing size is `default`, and `icon-sm` pairs with `sm`, `icon-xs` with `xs`, `icon` with default. Never `size="sm"` beside a default Button. The audit rules are `mixed-button-size` (static) and `mixed-button-height` (visual-audit, within 0.5px) (gh#1084).
11
11
  6. Text is `<Text>` / `<Heading>` with `tone`, `size`, `weight`, `truncate`, `mono` — not `className="text-muted-foreground font-semibold"`. A STANDALONE glyph is `<Icon as={Lock} size="sm" tone="muted" />` from `@godxjp/ui/general`: a bare lucide icon carries its own `width="24" height="24"` and only four places re-size it for you (`Button`, a `DropdownMenuItem`, a `TopbarItem`, a `ListRow` leading slot), so everywhere else — in a `Text`, a table cell, an `<a>` — it draws at 24px beside 14px type. `size-4` and `w-[16px]` are closed to you by rules 3 and 8, and `size={16}` hard-codes a number the theme owns; `Icon` puts the glyph on the `--icon-size-*` scale (`2xs…4xl`) instead, and is `aria-hidden` unless you give it a `label` (then it is `role="img"` with that name). The audit rule is `lucide-icon-needs-size`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "33.1.1",
4
- "godxUiMcp": "33.1.1",
3
+ "version": "33.3.0",
4
+ "godxUiMcp": "33.3.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -1011,7 +1011,19 @@ const RULES = [
1011
1011
  severity: "warn",
1012
1012
  spansElement: true,
1013
1013
  matches: formOnListPageMatches,
1014
- message: `A Form with ${FORM_ON_LIST_FIELDS}+ FormFields and a DataTable/Table/List/ListRow on the same page — a create form inlined beside its list. Give create its own route (or a focused Dialog/Sheet) and keep the list page a list (design-complex-admin anti-pattern #3).`,
1014
+ message: `A Form with ${FORM_ON_LIST_FIELDS}+ FormFields and a DataTable/Table/List/ListRow on the same page — a create form inlined beside its list. Give create its own route (or a focused Dialog/Sheet) and keep the list page a list (design-complex-admin anti-pattern #3). A SEARCH form over the list is not this: use <SearchForm> or mark the Form role="search".`,
1015
+ },
1016
+ {
1017
+ id: "hand-rolled-filter-bar",
1018
+ replacement:
1019
+ "<FilterBar> (filters packed at the start; past 2–4 filters, pattern list-filters)",
1020
+ scope: "consumer",
1021
+ severity: "warn",
1022
+ standard: "@godxjp/ui cardinal rule #51",
1023
+ spansElement: true,
1024
+ matches: handRolledFilterBarMatches,
1025
+ message:
1026
+ 'A hand-built filter strip on a list page: 2+ filter controls (labelled Select/SearchInput/Switch/Checkbox/DatePicker) laid out in your own Flex/div/Card above a DataTable/Table/List, outside FilterBar. Use <FilterBar> (search / filters / actions slots): it packs the filters at the START with one gap, wraps at every width and owns the clear + chips row. Never spread filters with justify="between"/"around"/"evenly" — the only far-end element of a filter bar is its `actions` slot. Past 2–4 filters, the rest go behind one "More filters" Sheet (MCP: get_rule 51, get_pattern list-filters). Not findings: a Form that submits (a search form), a SearchForm, a Sheet/Dialog/Drawer body, FilterBar/Toolbar children, a single search box. Many conditions or an explicit 検索 button → <SearchForm> (pattern search-form); field × operator rows → pattern condition-builder.',
1015
1027
  },
1016
1028
  {
1017
1029
  id: "empty-state-icon-in-card",
@@ -1850,7 +1862,10 @@ function* formOnListPageMatches(source) {
1850
1862
  // No onSubmit / action / spread: the Form cannot submit, so it is a filter or parameter
1851
1863
  // bar laid out with FormFields, like the NO_OP_SUBMIT shape.
1852
1864
  const submits = /\b(?:onSubmit|action)\s*=/.test(attrs) || /\{\s*\.\.\./.test(openTag);
1853
- const skip = overlay || !submits || NO_OP_SUBMIT.test(openTag);
1865
+ // `role="search"` marks a 検索条件フォーム: a submitting SEARCH over the list, not a create
1866
+ // form inlined beside it (the same shape SearchForm renders).
1867
+ const search = /\brole\s*=\s*\{?\s*["']search["']/.test(attrs);
1868
+ const skip = overlay || !submits || search || NO_OP_SUBMIT.test(openTag);
1854
1869
  opened.push({ index, openTag, scope, skip, fields: 0 });
1855
1870
  }
1856
1871
  } else if (tag === "FormField") {
@@ -1873,6 +1888,136 @@ function* formOnListPageMatches(source) {
1873
1888
  }
1874
1889
  }
1875
1890
 
1891
+ /** Value controls a list filter is built from (the label comes from FormField or aria-label). */
1892
+ const FILTER_VALUE_CONTROL =
1893
+ /^(?:Select|SearchSelect|SearchInput|Combobox|Switch|Checkbox|CheckboxGroup|Checkbox\.Group|RadioGroup|DatePicker|DateRangePicker|MonthPicker|Segmented|TreeSelect|Cascader)$/;
1894
+ /** A filter control here is already inside the library's filter surface. */
1895
+ const FILTER_SURFACE =
1896
+ /^(?:FilterBar|Toolbar|FilterBarGroup|ToolbarGroup|DataTable\.Toolbar|SearchForm)$/;
1897
+ /** Elements whose children are laid out by the consumer: the box a hand-built strip is drawn in. */
1898
+ const STRIP_CONTAINER =
1899
+ /^(?:Flex|div|section|header|Card|CardContent|CardHeader|CardBar|ResponsiveGrid|Grid|Space|Row)$/;
1900
+ const COLUMN_STACK = /\bdirection\s*=\s*\{?\s*["'](?:col|column)["']|(?<![\w-])flex-col(?![\w-])/;
1901
+ const SPREAD_JUSTIFY =
1902
+ /\bjustify\s*=\s*\{?\s*["'](?:between|around|evenly)["']|(?<![\w-])justify-(?:between|around|evenly)(?![\w-])/;
1903
+
1904
+ /**
1905
+ * `hand-rolled-filter-bar` (cardinal rule #51): a list page whose filters are laid out by hand.
1906
+ * gino-cloud's 送出機関 list put three FormFields (two Selects, a Switch) in
1907
+ * `<Card><CardContent><Flex wrap justify="between">`, each Select in its own `<Flex width={280}>`,
1908
+ * above a DataTable — three controls pushed to the far left, middle and far right of a wide card.
1909
+ * `formfield-needs-form` cannot see it (each field has a different immediate parent), so the
1910
+ * grouping here is by SUBTREE: every container counts the filter controls anywhere below it, and
1911
+ * the LOWEST container holding 2+ is the strip. A filter control is a FormField (not
1912
+ * `label={false}`) holding a value control, or a bare value control that names itself
1913
+ * (`aria-label`, `label`, or a SearchInput). Not counted: anything inside FilterBar/Toolbar and
1914
+ * its groups, an overlay (a Sheet of filters is the list-filters pattern), a Form that SUBMITS (a
1915
+ * search form; one that never submits is a filter strip in disguise, as in form-on-list-page), or
1916
+ * a table (an inline cell editor). The strip must share a page with a list surface, paired by
1917
+ * PageContainer exactly as `form-on-list-page` pairs them. Separately, a container that spreads
1918
+ * filters with justify between/around/evenly across 2+ of its direct children is named in the
1919
+ * snippet: that is the defect the consumer saw.
1920
+ */
1921
+ function* handRolledFilterBarMatches(source) {
1922
+ const submitSpans = [];
1923
+ for (const m of source.matchAll(/<(Form|FormRoot)(?=[\s>])/g)) {
1924
+ const end = jsxOpeningEnd(source, m.index);
1925
+ const openTag = source.slice(m.index, end + 1);
1926
+ if (/\/\s*>$/.test(openTag)) continue;
1927
+ const submits =
1928
+ (/\b(?:onSubmit|action)\s*=/.test(topLevelAttrs(openTag)) || /\{\s*\.\.\./.test(openTag)) &&
1929
+ !NO_OP_SUBMIT.test(openTag);
1930
+ if (!submits) continue;
1931
+ const close = closingTagRange(source, m[1], end + 1);
1932
+ submitSpans.push([m.index, close ? close.start : source.length]);
1933
+ }
1934
+ const inSubmittingForm = (i) => submitSpans.some(([a, b]) => i > a && i < b);
1935
+
1936
+ const openTags = new Map(); // children-frame id → { tag, index, openTag }
1937
+ const pendingOpen = []; // openings whose children frame has not been seen yet
1938
+ const units = new Map(); // unit key → frames above the control (a copy of the stack)
1939
+ const listScopes = new Set();
1940
+ for (const { tag, index, openTag, stack } of jsxElements(source)) {
1941
+ // A container's children frame appears in the stack after its opening tag closes: claim it.
1942
+ for (const f of stack) {
1943
+ if (!f.attr && !openTags.has(f.id) && pendingOpen.length) {
1944
+ const i = pendingOpen.findLastIndex((p) => p.tag === f.tag && p.index < index);
1945
+ if (i >= 0) openTags.set(f.id, pendingOpen.splice(i, 1)[0]);
1946
+ }
1947
+ }
1948
+ if (!/\/\s*>$/.test(openTag)) pendingOpen.push({ tag, index, openTag });
1949
+ const overlay = stack.some((f) => OVERLAY_TAG.test(f.tag));
1950
+ if (LIST_TAGS.has(tag)) {
1951
+ if (!overlay && !stack.some((f) => f.tag === "Form")) {
1952
+ listScopes.add(stack.findLast((f) => f.tag === "PageContainer" && !f.attr)?.id ?? "file");
1953
+ }
1954
+ continue;
1955
+ }
1956
+ if (!FILTER_VALUE_CONTROL.test(tag)) continue;
1957
+ if (
1958
+ overlay ||
1959
+ stack.some((f) => FILTER_SURFACE.test(f.tag) || LIST_TAGS.has(f.tag)) ||
1960
+ inSubmittingForm(index)
1961
+ ) {
1962
+ continue;
1963
+ }
1964
+ const field = stack.findLastIndex((f) => f.tag === "FormField" && !f.attr);
1965
+ if (field >= 0) {
1966
+ // `label={false}` is a layout slot (a submit row), not a labelled filter.
1967
+ const fieldTag = openTags.get(stack[field].id)?.openTag ?? "";
1968
+ if (/\blabel\s*=\s*\{\s*false\s*\}/.test(topLevelAttrs(fieldTag))) continue;
1969
+ const key = `field:${stack[field].id}`;
1970
+ if (!units.has(key)) units.set(key, stack.slice(0, field));
1971
+ } else if (
1972
+ !stack.some((f) => f.attr) &&
1973
+ (tag === "SearchInput" || /\b(?:aria-label|label)\s*=/.test(topLevelAttrs(openTag)))
1974
+ ) {
1975
+ units.set(`ctl:${index}`, stack.slice());
1976
+ }
1977
+ }
1978
+ if (units.size < 2 || listScopes.size === 0) return;
1979
+
1980
+ const counts = new Map(); // container frame id → { n, children: Set<child frame id> }
1981
+ for (const [key, frames] of units) {
1982
+ frames.forEach((f, i) => {
1983
+ if (f.attr || !STRIP_CONTAINER.test(f.tag)) return;
1984
+ const c =
1985
+ counts.get(f.id) ?? counts.set(f.id, { n: 0, children: new Set(), frames }).get(f.id);
1986
+ c.n += 1;
1987
+ c.children.add(frames[i + 1]?.id ?? key);
1988
+ });
1989
+ }
1990
+ const strips = [...counts.entries()].filter(([, c]) => c.n >= 2);
1991
+ const reported = new Set();
1992
+ for (const [id, c] of strips) {
1993
+ const open = openTags.get(id);
1994
+ if (!open) continue;
1995
+ const scope = c.frames.findLast((f) => f.tag === "PageContainer" && !f.attr)?.id ?? "file";
1996
+ const samePage = scope === "file" || listScopes.has("file") || listScopes.has(scope);
1997
+ if (!samePage) continue;
1998
+ const spread = SPREAD_JUSTIFY.test(open.openTag) && c.children.size >= 2;
1999
+ // The LOWEST container holding 2+ filters is the strip; an ancestor is reported only when it
2000
+ // is the one that spreads them.
2001
+ const lowest = !strips.some(([other, oc]) => {
2002
+ const at = oc.frames.findIndex((f) => f.id === id);
2003
+ return other !== id && at >= 0 && at < oc.frames.findIndex((f) => f.id === other);
2004
+ });
2005
+ if (!lowest && !spread) continue;
2006
+ // A vertical stack of fields is a form or an editor panel (theme editors, a settings column),
2007
+ // not a strip: a filter bar is a ROW. Measured on this repo's docs: all 6 column hits were
2008
+ // live-apply editors, every row/grid hit was a real filter strip.
2009
+ if (!spread && COLUMN_STACK.test(topLevelAttrs(open.openTag))) continue;
2010
+ if (reported.has(open.index)) continue;
2011
+ reported.add(open.index);
2012
+ yield {
2013
+ 0: spread
2014
+ ? `${c.n} filter controls spread by justify: ${open.openTag}`
2015
+ : `${c.n} filter controls outside a FilterBar: ${open.openTag}`,
2016
+ index: open.index,
2017
+ };
2018
+ }
2019
+ }
2020
+
1876
2021
  /** ≥3 StatCards in a file whose path does not say it is a dashboard. */
1877
2022
  function* kpiRowMatches(source, rel = "") {
1878
2023
  if (/dashboard/i.test(rel)) return;
@@ -2080,7 +2225,7 @@ function staleOwnedRules() {
2080
2225
  * Pagination also renders <Form>, but around its own controls, not a consumer's fields. Add a tag
2081
2226
  * here when another exported wrapper starts rendering <Form> around children.
2082
2227
  */
2083
- const FORM_TAGS = new Set(["Form", "FormRoot"]);
2228
+ const FORM_TAGS = new Set(["Form", "FormRoot", "SearchForm"]);
2084
2229
  const formOpenTag = new RegExp(`<(${[...FORM_TAGS].join("|")})(?=[\\s>])([^>]*)>`, "g");
2085
2230
  function formTagWalk(scanContent) {
2086
2231
  const formSpans = [];