@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.
- package/README.md +5 -5
- package/agent/START-HERE.md +7 -7
- package/agent/components/Command.json +9 -1
- package/agent/components/ResponsiveGrid.json +2 -2
- package/agent/components/SearchForm.json +115 -0
- package/agent/components/Toolbar.json +6 -3
- package/agent/components/TranslatableInput.json +92 -0
- package/agent/components/TranslatableTextarea.json +84 -0
- package/agent/components-index.json +20 -1
- package/agent/components.json +308 -6
- package/agent/index.json +8 -8
- package/agent/llms.txt +8 -8
- package/agent/patterns/condition-builder.json +31 -0
- package/agent/patterns/data-table-page.json +1 -1
- package/agent/patterns/filter-bar-layouts.json +30 -0
- package/agent/patterns/inertia-list-page.json +1 -1
- package/agent/patterns/list-filters.json +1 -1
- package/agent/patterns/search-form.json +27 -0
- package/agent/patterns-index.json +85 -0
- package/agent/patterns.json +91 -3
- package/agent/rules.json +1 -1
- package/agent/tokens.json +12 -0
- package/dist/components/data-entry/command.d.ts +22 -1
- package/dist/components/data-entry/command.js +14 -1
- package/dist/components/data-entry/form-field.js +10 -4
- package/dist/components/data-entry/index.d.ts +4 -2
- package/dist/components/data-entry/index.js +6 -1
- package/dist/components/data-entry/translatable-input.d.ts +29 -0
- package/dist/components/data-entry/translatable-input.js +184 -0
- package/dist/components/navigation/index.d.ts +2 -0
- package/dist/components/navigation/index.js +2 -0
- package/dist/components/navigation/search-form.d.ts +31 -0
- package/dist/components/navigation/search-form.js +112 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +13 -0
- package/dist/i18n/messages/ja.json +13 -0
- package/dist/i18n/messages/vi.json +13 -0
- package/dist/lib/field-a11y.d.ts +15 -0
- package/dist/lib/field-a11y.js +13 -1
- package/dist/lib/intl-cache.d.ts +1 -0
- package/dist/lib/intl-cache.js +4 -0
- package/dist/props/components/data-entry.prop.d.ts +46 -0
- package/dist/props/components/index.d.ts +1 -1
- package/dist/props/components/layout.prop.d.ts +3 -1
- package/dist/props/components/navigation.prop.d.ts +43 -3
- package/dist/props/registry.d.ts +56 -0
- package/dist/props/registry.js +91 -0
- package/dist/styles/control.css +15 -0
- package/dist/styles/data-entry-layout.css +25 -0
- package/dist/styles/layers.json +29 -1
- package/dist/styles/layout.css +41 -0
- package/dist/styles.css +1 -1
- package/dist/tokens/components/data-entry.css +4 -0
- package/docs/CONSUMER-RULES.md +1 -1
- package/package.json +2 -2
- package/scripts/ui-audit.mjs +148 -3
package/docs/CONSUMER-RULES.md
CHANGED
|
@@ -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
|
|
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
package/scripts/ui-audit.mjs
CHANGED
|
@@ -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
|
-
|
|
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 = [];
|