@godxjp/ui 30.5.3 → 30.7.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.
@@ -3,7 +3,7 @@
3
3
  You are about to write code against a design system you did not author. This file is the whole
4
4
  contract. Read it before you write JSX.
5
5
 
6
- **This catalog describes `@godxjp/ui` 30.5.3.** If the project you are editing has a different
6
+ **This catalog describes `@godxjp/ui` 30.7.0.** If the project you are editing has a different
7
7
  version in its `package.json`, read the pinned catalog for THAT version instead
8
8
  (`…/v<their-version>/agent/…`). A catalog newer than the installed package describes props that do
9
9
  not exist yet; older, and it hides props that do. Neither failure announces itself.
@@ -47,7 +47,7 @@ Then ask it: `search_components`, `get_component`, `get_tokens`, `get_rule`, `li
47
47
  task is a task** — "build a settings page", "confirm a destructive delete", "a list page with
48
48
  filters" — start HERE, not at the components. Then fetch `patterns/<name>.json` for complete,
49
49
  copy-paste-ready code. A component index answers "does X exist"; it cannot answer "build Y".
50
- 1. `components-index.json` — 46 KB, all 173 components as name + group +
50
+ 1. `components-index.json` — 46 KB, all 175 components as name + group +
51
51
  tagline. Read this when you already know the SHAPE you need. Each entry may carry `absorbed`:
52
52
  names that **do not exist** and map to it — `Combobox`, `Autocomplete`, `CountrySelect` and
53
53
  `SearchSelect` are all `Select`. If you are about to hand-roll something, search this field
@@ -56,10 +56,10 @@ Then ask it: `search_components`, `get_component`, `get_tokens`, `get_rule`, `li
56
56
  its `importPath`, and its examples. Fetch only the handful you picked in step 1.
57
57
  3. `rules.json` — 50 cardinal rules. The ones about raw HTML and hardcoded colour are not
58
58
  style advice.
59
- 4. `tokens.json` — 2073 design tokens, each tagged with its `tier`. **If you were handed a
59
+ 4. `tokens.json` — 2074 design tokens, each tagged with its `tier`. **If you were handed a
60
60
  brand, read the 211 `foundation` entries first** — `--primary`, `--background`,
61
61
  `--radius`, `--font-size-base` are the handful everything else derives from. The
62
- 1759 `component` entries are per-part knobs; reach for one only when a role is
62
+ 1760 `component` entries are per-part knobs; reach for one only when a role is
63
63
  right everywhere except one component.
64
64
  5. `anti-ai-tells.json` — 26 shapes that make generated UI look generated, each with the
65
65
  fix. Read before you reach for a gradient hero or a wall of coloured chips.
@@ -145,7 +145,7 @@ has stopped following the brand.
145
145
  |---|---|---|---|
146
146
  | `foundation` | 211 | the seeds — `--primary`, `--background`, `--foreground`, `--radius`, `--font-size-base`, `--shadow-color`. Everything below is derived from these | **yes — this is the main road.** Handed a brand colour, this is where it goes: `:root { --primary: <H> <S>% <L>%; }` (HSL components, no `hsl()` wrapper) |
147
147
  | `semantic` | 103 | named roles that follow the seeds — `--ring`, `--text-link`, `--primary-hover`, `--overlay-background` | only when the seed is right and ONE role must differ. That role then stops following a later brand change |
148
- | `component` | 1759 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
148
+ | `component` | 1760 | per-part knobs, `--{component}-{part}-{property}` | rarely. Most are declared `initial` with the real default at the call site — deliberate, so a scoped override re-resolves instead of freezing at `:root` |
149
149
 
150
150
  A token whose `value` is `initial` is not empty and not broken: `initial` is the guaranteed-invalid
151
151
  value, so the real default is computed where the element paints it. Set it and yours wins.
@@ -10,6 +10,12 @@
10
10
  "name": "mode",
11
11
  "type": "\"single\" | \"multiple\""
12
12
  },
13
+ {
14
+ "defaultValue": "\"auto\"",
15
+ "description": "TWO ENTRY POINTS AT ONCE, instead of \"dropdown OR dialog\" (gh#944). `auto` keeps the original behaviour: `threshold` picks one. `inline` opens both — a typeable field with suggestions REGARDLESS of `count`, and a 「検索」 button beside it that always shows and opens the dialog with filters + pagination; the half-typed text carries into the dialog as its initial query. Reach for it when `count > threshold` is almost always true: a consumer measured customer projects at hundreds-to-thousands of records, so the dialog-only branch was the one users met most, and there they lost the ability to type a key they already knew. The threshold answers \"is the set big\"; the real question is \"does this person already know what they want\" — `inline` stops forcing a choice. NOTE: the forwarded ref lands on the <input> under `inline` and on the trigger <button> otherwise.",
16
+ "name": "shape",
17
+ "type": "\"auto\" | \"inline\""
18
+ },
13
19
  {
14
20
  "defaultValue": "10",
15
21
  "description": "The dropdown/dialog switch. A dropdown is the right control for eight people and the wrong one for eight hundred; this is where that line is drawn, once, by a service rather than per screen.",
@@ -38,10 +38,6 @@
38
38
  31
39
39
  ],
40
40
  "storyPath": "feedback/Skeleton.stories.tsx",
41
- "subParts": [
42
- "SkeletonDetail",
43
- "SkeletonStat"
44
- ],
45
41
  "tagline": "Base pulsing skeleton block, and the namespace the shaped presets hang off (Skeleton.Avatar / .Button / .Input / .Node / .Image / .Article).",
46
42
  "usage": [
47
43
  "DO use Skeleton for a custom block when SkeletonRows/Table/Stat/Article do not match the final layout.",
@@ -0,0 +1,31 @@
1
+ {
2
+ "example": "import { SkeletonDetail } from \"@godxjp/ui/feedback\";\n\n<SkeletonDetail />",
3
+ "group": "feedback",
4
+ "importPath": "@godxjp/ui/feedback",
5
+ "name": "SkeletonDetail",
6
+ "props": [],
7
+ "related": [
8
+ "SkeletonStat — the same propless treatment for a KPI tile.",
9
+ "SkeletonRows — flat list lines, not label/value pairs.",
10
+ "SkeletonTable — tabular placeholder with a header band.",
11
+ "Skeleton — the bare block this preset is built from; reach for it when you need a shape this one does not draw.",
12
+ "Descriptions — the component this placeholder is standing in for."
13
+ ],
14
+ "rules": [
15
+ 3,
16
+ 31
17
+ ],
18
+ "storyPath": "feedback/Skeleton.stories.tsx",
19
+ "tagline": "Fixed-shape placeholder for a RECORD page — title, subtitle, then a bordered box of six label/value pairs. Takes no props: the shape IS the contract, so every detail screen loads identically.",
20
+ "usage": [
21
+ "DO use it while a single record's data resolves — a detail page, a drawer showing one entity, a Descriptions block.",
22
+ "DO leave it propless. It has no `rows` knob on purpose: a record page that loads with a different number of lines each time reads as a layout bug, and `Skeleton` is there for a bespoke shape.",
23
+ "DON'T use it for a list or a table — SkeletonRows draws flat lines, SkeletonTable draws a header band.",
24
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
25
+ ],
26
+ "useCases": [
27
+ "Detail page body before the record arrives",
28
+ "Drawer or sheet opened on a row while its full entity loads",
29
+ "Descriptions block placeholder"
30
+ ]
31
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "example": "import { SkeletonStat } from \"@godxjp/ui/feedback\";\nimport { ResponsiveGrid } from \"@godxjp/ui/layout\";\n\n<ResponsiveGrid columns={{ base: 1, sm: 2, lg: 4 }} gap=\"md\">\n {Array.from({ length: 4 }).map((_, i) => (\n <SkeletonStat key={i} />\n ))}\n</ResponsiveGrid>",
3
+ "group": "feedback",
4
+ "importPath": "@godxjp/ui/feedback",
5
+ "name": "SkeletonStat",
6
+ "props": [],
7
+ "related": [
8
+ "StatCard — the loaded component this stands in for; match the grid, not just the size.",
9
+ "SkeletonDetail — the same propless treatment for a record page.",
10
+ "SkeletonRows — flat list lines.",
11
+ "Skeleton — the bare block this preset is built from."
12
+ ],
13
+ "rules": [
14
+ 3,
15
+ 31
16
+ ],
17
+ "storyPath": "feedback/Skeleton.stories.tsx",
18
+ "tagline": "Fixed-shape placeholder for ONE KPI tile — label, big value, caption. Takes no props, so a row of tiles loads at a uniform height and nothing reflows when the numbers arrive.",
19
+ "usage": [
20
+ "DO render one per StatCard while the figures load, inside the same grid the loaded tiles use.",
21
+ "DO leave it propless — it mirrors StatCard's three-line rhythm exactly, which is what stops the row jumping on hydration.",
22
+ "DON'T wrap it in a Card yourself if the loaded state uses StatCard; put it where the StatCard goes so the surface is identical.",
23
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
24
+ ],
25
+ "useCases": [
26
+ "Dashboard KPI row before the metrics query resolves",
27
+ "A single figure on a detail page loading separately from the record",
28
+ "Placeholder inside a ResponsiveGrid of StatCards"
29
+ ]
30
+ }
@@ -607,6 +607,16 @@
607
607
  "name": "Skeleton",
608
608
  "tagline": "Base pulsing skeleton block, and the namespace the shaped presets hang off (Skeleton.Avatar / .Button / .Input / .Node / .Image / .Article)."
609
609
  },
610
+ {
611
+ "group": "feedback",
612
+ "name": "SkeletonDetail",
613
+ "tagline": "Fixed-shape placeholder for a RECORD page — title, subtitle, then a bordered box of six label/value pairs. Takes no props: the shape IS the contract, so every detail screen loads identically."
614
+ },
615
+ {
616
+ "group": "feedback",
617
+ "name": "SkeletonStat",
618
+ "tagline": "Fixed-shape placeholder for ONE KPI tile — label, big value, caption. Takes no props, so a row of tiles loads at a uniform height and nothing reflows when the numbers arrive."
619
+ },
610
620
  {
611
621
  "group": "feedback",
612
622
  "name": "SkeletonArticle",
@@ -5996,6 +5996,12 @@
5996
5996
  "name": "mode",
5997
5997
  "type": "\"single\" | \"multiple\""
5998
5998
  },
5999
+ {
6000
+ "defaultValue": "\"auto\"",
6001
+ "description": "TWO ENTRY POINTS AT ONCE, instead of \"dropdown OR dialog\" (gh#944). `auto` keeps the original behaviour: `threshold` picks one. `inline` opens both — a typeable field with suggestions REGARDLESS of `count`, and a 「検索」 button beside it that always shows and opens the dialog with filters + pagination; the half-typed text carries into the dialog as its initial query. Reach for it when `count > threshold` is almost always true: a consumer measured customer projects at hundreds-to-thousands of records, so the dialog-only branch was the one users met most, and there they lost the ability to type a key they already knew. The threshold answers \"is the set big\"; the real question is \"does this person already know what they want\" — `inline` stops forcing a choice. NOTE: the forwarded ref lands on the <input> under `inline` and on the trigger <button> otherwise.",
6002
+ "name": "shape",
6003
+ "type": "\"auto\" | \"inline\""
6004
+ },
5999
6005
  {
6000
6006
  "defaultValue": "10",
6001
6007
  "description": "The dropdown/dialog switch. A dropdown is the right control for eight people and the wrong one for eight hundred; this is where that line is drawn, once, by a service rather than per screen.",
@@ -11474,10 +11480,6 @@
11474
11480
  31
11475
11481
  ],
11476
11482
  "storyPath": "feedback/Skeleton.stories.tsx",
11477
- "subParts": [
11478
- "SkeletonDetail",
11479
- "SkeletonStat"
11480
- ],
11481
11483
  "tagline": "Base pulsing skeleton block, and the namespace the shaped presets hang off (Skeleton.Avatar / .Button / .Input / .Node / .Image / .Article).",
11482
11484
  "usage": [
11483
11485
  "DO use Skeleton for a custom block when SkeletonRows/Table/Stat/Article do not match the final layout.",
@@ -11491,6 +11493,67 @@
11491
11493
  "Inline metadata placeholder"
11492
11494
  ]
11493
11495
  },
11496
+ {
11497
+ "example": "import { SkeletonDetail } from \"@godxjp/ui/feedback\";\n\n<SkeletonDetail />",
11498
+ "group": "feedback",
11499
+ "importPath": "@godxjp/ui/feedback",
11500
+ "name": "SkeletonDetail",
11501
+ "props": [],
11502
+ "related": [
11503
+ "SkeletonStat — the same propless treatment for a KPI tile.",
11504
+ "SkeletonRows — flat list lines, not label/value pairs.",
11505
+ "SkeletonTable — tabular placeholder with a header band.",
11506
+ "Skeleton — the bare block this preset is built from; reach for it when you need a shape this one does not draw.",
11507
+ "Descriptions — the component this placeholder is standing in for."
11508
+ ],
11509
+ "rules": [
11510
+ 3,
11511
+ 31
11512
+ ],
11513
+ "storyPath": "feedback/Skeleton.stories.tsx",
11514
+ "tagline": "Fixed-shape placeholder for a RECORD page — title, subtitle, then a bordered box of six label/value pairs. Takes no props: the shape IS the contract, so every detail screen loads identically.",
11515
+ "usage": [
11516
+ "DO use it while a single record's data resolves — a detail page, a drawer showing one entity, a Descriptions block.",
11517
+ "DO leave it propless. It has no `rows` knob on purpose: a record page that loads with a different number of lines each time reads as a layout bug, and `Skeleton` is there for a bespoke shape.",
11518
+ "DON'T use it for a list or a table — SkeletonRows draws flat lines, SkeletonTable draws a header band.",
11519
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
11520
+ ],
11521
+ "useCases": [
11522
+ "Detail page body before the record arrives",
11523
+ "Drawer or sheet opened on a row while its full entity loads",
11524
+ "Descriptions block placeholder"
11525
+ ]
11526
+ },
11527
+ {
11528
+ "example": "import { SkeletonStat } from \"@godxjp/ui/feedback\";\nimport { ResponsiveGrid } from \"@godxjp/ui/layout\";\n\n<ResponsiveGrid columns={{ base: 1, sm: 2, lg: 4 }} gap=\"md\">\n {Array.from({ length: 4 }).map((_, i) => (\n <SkeletonStat key={i} />\n ))}\n</ResponsiveGrid>",
11529
+ "group": "feedback",
11530
+ "importPath": "@godxjp/ui/feedback",
11531
+ "name": "SkeletonStat",
11532
+ "props": [],
11533
+ "related": [
11534
+ "StatCard — the loaded component this stands in for; match the grid, not just the size.",
11535
+ "SkeletonDetail — the same propless treatment for a record page.",
11536
+ "SkeletonRows — flat list lines.",
11537
+ "Skeleton — the bare block this preset is built from."
11538
+ ],
11539
+ "rules": [
11540
+ 3,
11541
+ 31
11542
+ ],
11543
+ "storyPath": "feedback/Skeleton.stories.tsx",
11544
+ "tagline": "Fixed-shape placeholder for ONE KPI tile — label, big value, caption. Takes no props, so a row of tiles loads at a uniform height and nothing reflows when the numbers arrive.",
11545
+ "usage": [
11546
+ "DO render one per StatCard while the figures load, inside the same grid the loaded tiles use.",
11547
+ "DO leave it propless — it mirrors StatCard's three-line rhythm exactly, which is what stops the row jumping on hydration.",
11548
+ "DON'T wrap it in a Card yourself if the loaded state uses StatCard; put it where the StatCard goes so the surface is identical.",
11549
+ "Import from `@godxjp/ui/feedback` (canonical). `@godxjp/ui/admin` re-exports it for admin pages."
11550
+ ],
11551
+ "useCases": [
11552
+ "Dashboard KPI row before the metrics query resolves",
11553
+ "A single figure on a detail page loading separately from the record",
11554
+ "Placeholder inside a ResponsiveGrid of StatCards"
11555
+ ]
11556
+ },
11494
11557
  {
11495
11558
  "example": "import { SkeletonArticle } from \"@godxjp/ui/feedback\";\n\n<SkeletonArticle avatar active paragraph={{ rows: 3 }} />",
11496
11559
  "group": "feedback",
package/agent/index.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "counts": {
3
3
  "anti-ai-tells": 26,
4
- "components": 173,
4
+ "components": 175,
5
5
  "patterns": 21,
6
6
  "rules": 50,
7
- "tokens": 2073,
7
+ "tokens": 2074,
8
8
  "vocabulary": 14
9
9
  },
10
10
  "files": [
11
11
  {
12
12
  "file": "components-index.json",
13
- "note": "46 KB — name + group + tagline for all 173. FETCH THIS FIRST, then fetch only the components you chose.",
13
+ "note": "46 KB — name + group + tagline for all 175. FETCH THIS FIRST, then fetch only the components you chose.",
14
14
  "url": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json"
15
15
  },
16
16
  {
@@ -48,19 +48,19 @@
48
48
  "note": "Pin to the tag that matches the @godxjp/ui version you installed. A catalog newer than your package describes props you do not have; older, and it hides props you do.",
49
49
  "read": {
50
50
  "live": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/index.json",
51
- "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.5.3/agent/index.json"
51
+ "pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.7.0/agent/index.json"
52
52
  },
53
53
  "source": "mcp/src/data — the same data @godxjp/ui-mcp serves — plus the foundation and semantic token tiers, read from src/tokens/*.css",
54
54
  "start": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/START-HERE.md",
55
55
  "tokenTiers": {
56
56
  "component": "per-part knobs, --{component}-{part}-{property}; usually leave these alone",
57
57
  "counts": {
58
- "component": 1759,
58
+ "component": 1760,
59
59
  "foundation": 211,
60
60
  "semantic": 103
61
61
  },
62
62
  "foundation": "the seeds a consumer is invited to set — --primary, --background, --radius",
63
63
  "semantic": "named roles that follow the seeds — --ring, --text-link, --overlay-background"
64
64
  },
65
- "version": "30.5.3"
65
+ "version": "30.7.0"
66
66
  }
package/agent/llms.txt CHANGED
@@ -1,10 +1,10 @@
1
1
  # @godxjp/ui
2
2
 
3
- > A Japanese-enterprise React design system: 173 components, 2073 design tokens,
4
- > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.5.3.
3
+ > A Japanese-enterprise React design system: 175 components, 2074 design tokens,
4
+ > 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.7.0.
5
5
 
6
6
  If your client can run a process, do not read these files — run the MCP server instead
7
- (`npx @godxjp/ui-mcp@30.5.3`). It is searchable and version-locked. These files exist for agents
7
+ (`npx @godxjp/ui-mcp@30.7.0`). It is searchable and version-locked. These files exist for agents
8
8
  that can only fetch URLs.
9
9
 
10
10
  ## Start
@@ -15,10 +15,10 @@ that can only fetch URLs.
15
15
  ## Catalog
16
16
 
17
17
  - [patterns-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/patterns-index.json): 21 whole-task patterns (name, tagline, tags). Start here when the task is a TASK — "build a settings page" — then fetch `patterns/<name>.json` for complete code.
18
- - [components-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json): 46 KB — all 173 components as name, group, tagline, plus `absorbed`: the names that do NOT exist and map to it (`Combobox` → `Select`).
18
+ - [components-index.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components-index.json): 46 KB — all 175 components as name, group, tagline, plus `absorbed`: the names that do NOT exist and map to it (`Combobox` → `Select`).
19
19
  - [components/&lt;Name&gt;.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components/Select.json): one file per component (1 KB–34 KB, median 6 KB). Read the index, then fetch only the ones you chose — this is the selective route, and the reason you do not need the blob.
20
20
  - [components.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/components.json): 1.2 MB — every entry in one file. Most URL fetchers truncate a response this size without saying so; prefer the per-component files.
21
- - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 103 `semantic` roles, 1759 `component` knobs.
21
+ - [tokens.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/tokens.json): every design token with its value, the reason it exists, and its `tier` — 211 `foundation` seeds (`--primary`, `--background`, `--radius`: set these when you are handed a brand), 103 `semantic` roles, 1760 `component` knobs.
22
22
  - [vocabulary.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/vocabulary.json): the controlled prop vocabulary — which prop name means what, across every component.
23
23
  - [rules.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/rules.json): 50 cardinal rules.
24
24
  - [anti-ai-tells.json](https://raw.githubusercontent.com/godx-jp/godxjp-ui/main/agent/anti-ai-tells.json): 26 shapes that make generated UI look generated, each with its fix.
@@ -26,7 +26,7 @@ that can only fetch URLs.
26
26
  ## Pinning
27
27
 
28
28
  Every URL above tracks `main`. To pin to the release a project actually installed, swap `main` for
29
- the tag: `.../godx-jp/godxjp-ui/v30.5.3/agent/...`. A catalog that does not match the installed
29
+ the tag: `.../godx-jp/godxjp-ui/v30.7.0/agent/...`. A catalog that does not match the installed
30
30
  package describes props that are absent, or hides props that are present, and says nothing either way.
31
31
 
32
32
  Pinned catalogs only exist for releases whose tag actually contains `agent/`. If `…/v<version>/agent/index.json` returns 404, that release predates this catalog: read `…/main/…` instead and compare `index.json` → `version` against the package you have, so you at least know which way it drifted.
package/agent/tokens.json CHANGED
@@ -5891,6 +5891,12 @@
5891
5891
  "tier": "component",
5892
5892
  "value": "22rem"
5893
5893
  },
5894
+ {
5895
+ "description": "Danh sách gợi ý dưới ô inline (gh#944) — ngắn hơn danh sách trong Dialog, vì nó nằm TRONG dòng chảy của form chứ không phải trong một lớp phủ riêng.",
5896
+ "name": "--record-picker-suggest-max-block-size",
5897
+ "tier": "component",
5898
+ "value": "14rem"
5899
+ },
5894
5900
  {
5895
5901
  "description": "Data-entry component tokens — small-by-design text knobs (rule #45/#46).",
5896
5902
  "name": "--password-strength-score-font-size",
@@ -35,6 +35,7 @@ export type { RecordPickerProp, RecordPickerProp as RecordPickerProps };
35
35
  */
36
36
  export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "defaultValue" | "onChange" | "value"> & {
37
37
  mode?: "single" | "multiple";
38
+ shape?: "auto" | "inline";
38
39
  value?: string | string[] | null;
39
40
  defaultValue?: string | string[] | null;
40
41
  onValueChange?: (value: string | string[] | null) => void;
@@ -60,4 +61,4 @@ export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.Bu
60
61
  dialogTitle?: string;
61
62
  size?: "xs" | "sm" | "md" | "lg";
62
63
  "data-field"?: string;
63
- } & React.RefAttributes<HTMLButtonElement>>;
64
+ } & React.RefAttributes<HTMLButtonElement | HTMLInputElement>>;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
- import { Check, ChevronDown, X } from "lucide-react";
3
+ import { Check, ChevronDown, Search, X } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
6
  import { cn } from "../../lib/utils.js";
@@ -24,6 +24,7 @@ import { Select } from "./select.js";
24
24
  const RecordPicker = React.forwardRef(
25
25
  function RecordPicker2({
26
26
  mode = "single",
27
+ shape = "auto",
27
28
  value,
28
29
  defaultValue,
29
30
  onValueChange,
@@ -71,6 +72,29 @@ const RecordPicker = React.forwardRef(
71
72
  () => options ? normalizeSelectOptions(options) : [],
72
73
  [options]
73
74
  );
75
+ if (shape === "inline") {
76
+ return /* @__PURE__ */ jsx(
77
+ InlinePicker,
78
+ {
79
+ ref,
80
+ isMultiple,
81
+ selected,
82
+ commit,
83
+ staticOptions,
84
+ loadOptions,
85
+ filters,
86
+ selectedOptions,
87
+ emptyOption,
88
+ placeholder,
89
+ dialogTitle,
90
+ disabled,
91
+ size,
92
+ className,
93
+ "data-field": fieldName,
94
+ ...props
95
+ }
96
+ );
97
+ }
74
98
  const total = count ?? (options ? staticOptions.length : void 0);
75
99
  const asDialog = total === void 0 ? Boolean(loadOptions) : total > threshold;
76
100
  if (!asDialog) {
@@ -117,6 +141,7 @@ const RecordPicker = React.forwardRef(
117
141
  );
118
142
  }
119
143
  );
144
+ const INLINE_SUGGESTION_LIMIT = 8;
120
145
  function toArray(v) {
121
146
  if (v === null || v === void 0) return [];
122
147
  return Array.isArray(v) ? v : [v];
@@ -125,6 +150,155 @@ function withEmptyOption(options, emptyOption) {
125
150
  if (!emptyOption) return options;
126
151
  return [{ value: emptyOption.value, label: emptyOption.label }, ...options ?? []];
127
152
  }
153
+ const InlinePicker = React.forwardRef(function InlinePicker2({
154
+ isMultiple,
155
+ selected,
156
+ commit,
157
+ staticOptions,
158
+ loadOptions,
159
+ filters,
160
+ selectedOptions,
161
+ emptyOption,
162
+ placeholder,
163
+ dialogTitle,
164
+ disabled,
165
+ size,
166
+ className,
167
+ id,
168
+ "data-field": fieldName,
169
+ "aria-describedby": describedBy
170
+ }, ref) {
171
+ const { t } = useTranslation();
172
+ const [query, setQuery] = React.useState("");
173
+ const [rows, setRows] = React.useState(staticOptions);
174
+ const [openSuggest, setOpenSuggest] = React.useState(false);
175
+ const [dialogQuery, setDialogQuery] = React.useState(null);
176
+ const labelCache = React.useRef(/* @__PURE__ */ new Map());
177
+ for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) {
178
+ labelCache.current.set(o.value, o);
179
+ }
180
+ React.useEffect(() => {
181
+ if (!loadOptions || query.trim() === "") return;
182
+ const id2 = setTimeout(() => {
183
+ void loadOptions({ query, filters: {} }).then((r) => setRows(r.options)).catch(() => void 0);
184
+ }, 250);
185
+ return () => clearTimeout(id2);
186
+ }, [query, loadOptions]);
187
+ const suggestions = React.useMemo(() => {
188
+ const q = query.trim().toLowerCase();
189
+ const base = loadOptions ? rows : staticOptions;
190
+ return (q ? base.filter((o) => (o.label + (o.sublabel ?? "")).toLowerCase().includes(q)) : base).slice(0, INLINE_SUGGESTION_LIMIT);
191
+ }, [query, rows, staticOptions, loadOptions]);
192
+ const chosen = selected.map((v) => labelCache.current.get(v) ?? { value: v, label: v });
193
+ const pick = (v) => {
194
+ commit(isMultiple ? [...selected, v] : [v]);
195
+ setQuery("");
196
+ setOpenSuggest(false);
197
+ };
198
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
199
+ /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: 1, className: cn("ui-record-picker-inline", className), children: [
200
+ /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", children: [
201
+ /* @__PURE__ */ jsx(
202
+ Input,
203
+ {
204
+ ref,
205
+ type: "search",
206
+ value: query,
207
+ disabled,
208
+ placeholder: placeholder ?? t("dataEntry.recordPicker.placeholder"),
209
+ onChange: (e) => {
210
+ setQuery(e.target.value);
211
+ setOpenSuggest(true);
212
+ },
213
+ onFocus: () => setOpenSuggest(true),
214
+ className: "ui-record-picker-inline-input",
215
+ id,
216
+ "data-field": fieldName,
217
+ "aria-describedby": describedBy
218
+ }
219
+ ),
220
+ /* @__PURE__ */ jsxs(
221
+ Button,
222
+ {
223
+ type: "button",
224
+ variant: "outline",
225
+ size: size === "lg" ? "lg" : "default",
226
+ disabled,
227
+ "aria-haspopup": "dialog",
228
+ onClick: () => setDialogQuery(query),
229
+ children: [
230
+ /* @__PURE__ */ jsx(Search, { "aria-hidden": "true" }),
231
+ t("dataEntry.recordPicker.openSearch")
232
+ ]
233
+ }
234
+ ),
235
+ chosen.length > 0 && !disabled ? /* @__PURE__ */ jsx(
236
+ Button,
237
+ {
238
+ variant: "ghost",
239
+ size: "icon-sm",
240
+ "aria-label": t("dataEntry.recordPicker.clear"),
241
+ onClick: () => commit([]),
242
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
243
+ }
244
+ ) : null
245
+ ] }),
246
+ chosen.length > 0 ? /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chosen.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
247
+ c.icon,
248
+ c.label,
249
+ c.sublabel ? ` ${c.sublabel}` : ""
250
+ ] }, c.value)) }) : null,
251
+ openSuggest && suggestions.length > 0 ? /* @__PURE__ */ jsx(
252
+ Command,
253
+ {
254
+ shouldFilter: false,
255
+ className: "ui-record-picker-suggest",
256
+ "aria-label": t("dataEntry.recordPicker.suggestions"),
257
+ children: suggestions.map((o) => /* @__PURE__ */ jsxs(
258
+ "button",
259
+ {
260
+ type: "button",
261
+ role: "option",
262
+ "aria-selected": selected.includes(o.value),
263
+ "data-picked": selected.includes(o.value) ? "" : void 0,
264
+ className: "ui-record-picker-option",
265
+ onClick: () => pick(o.value),
266
+ children: [
267
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: selected.includes(o.value) ? /* @__PURE__ */ jsx(Check, {}) : null }),
268
+ /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
269
+ o.label,
270
+ o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
271
+ ] })
272
+ ]
273
+ },
274
+ o.value
275
+ ))
276
+ }
277
+ ) : null
278
+ ] }),
279
+ dialogQuery !== null ? /* @__PURE__ */ jsx(
280
+ DialogPicker,
281
+ {
282
+ isMultiple,
283
+ selected,
284
+ commit: (next) => {
285
+ commit(next);
286
+ setDialogQuery(null);
287
+ setQuery("");
288
+ },
289
+ staticOptions,
290
+ loadOptions,
291
+ filters,
292
+ selectedOptions,
293
+ emptyOption,
294
+ dialogTitle,
295
+ initialQuery: dialogQuery,
296
+ autoOpen: true,
297
+ onDismiss: () => setDialogQuery(null)
298
+ }
299
+ ) : null
300
+ ] });
301
+ });
128
302
  const DialogPicker = React.forwardRef(function DialogPicker2({
129
303
  isMultiple,
130
304
  selected,
@@ -139,11 +313,14 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
139
313
  disabled,
140
314
  size,
141
315
  className,
316
+ initialQuery,
317
+ autoOpen = false,
318
+ onDismiss,
142
319
  ...props
143
320
  }, ref) {
144
321
  const { t } = useTranslation();
145
- const [open, setOpen] = React.useState(false);
146
- const [query, setQuery] = React.useState("");
322
+ const [open, setOpen] = React.useState(autoOpen);
323
+ const [query, setQuery] = React.useState(initialQuery ?? "");
147
324
  const [filterValues, setFilterValues] = React.useState({});
148
325
  const [rows, setRows] = React.useState(staticOptions);
149
326
  const [status, setStatus] = React.useState("idle");
@@ -207,7 +384,7 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
207
384
  };
208
385
  const chips = selected.map((v) => labels.get(v) ?? { value: v, label: v });
209
386
  return /* @__PURE__ */ jsxs(Fragment, { children: [
210
- /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", className: "ui-record-picker-row", children: [
387
+ autoOpen ? null : /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", className: "ui-record-picker-row", children: [
211
388
  /* @__PURE__ */ jsxs(
212
389
  Button,
213
390
  {
@@ -240,118 +417,128 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
240
417
  }
241
418
  ) : null
242
419
  ] }),
243
- /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
244
- /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
245
- /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
246
- /* @__PURE__ */ jsx(
247
- Input,
248
- {
249
- autoFocus: true,
250
- type: "search",
251
- value: query,
252
- onChange: (e) => setQuery(e.target.value),
253
- "aria-label": t("dataEntry.recordPicker.search"),
254
- placeholder: t("dataEntry.recordPicker.searchPlaceholder")
255
- }
256
- ),
257
- filters?.length ? /* @__PURE__ */ jsx(
258
- Flex,
259
- {
260
- direction: "row",
261
- gap: "sm",
262
- wrap: true,
263
- role: "group",
264
- "aria-label": t("dataEntry.recordPicker.filters"),
265
- children: filters.map((f) => /* @__PURE__ */ jsx(
266
- Select,
420
+ /* @__PURE__ */ jsx(
421
+ Dialog,
422
+ {
423
+ open,
424
+ onOpenChange: (next) => {
425
+ setOpen(next);
426
+ if (!next) onDismiss?.();
427
+ },
428
+ children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
429
+ /* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
430
+ /* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
431
+ /* @__PURE__ */ jsx(
432
+ Input,
267
433
  {
268
- size: "sm",
269
- "aria-label": f.label,
270
- placeholder: f.label,
271
- value: filterValues[f.name] ?? "",
272
- onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
273
- options: [
274
- { value: "", label: t("dataEntry.recordPicker.allFilter") },
275
- ...f.options
276
- ]
277
- },
278
- f.name
279
- ))
280
- }
281
- ) : null,
282
- /* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
283
- EmptyState,
284
- {
285
- variant: "compact",
286
- tone: "destructive",
287
- title: t("dataEntry.recordPicker.error"),
288
- action: /* @__PURE__ */ jsx(
434
+ autoFocus: true,
435
+ type: "search",
436
+ value: query,
437
+ onChange: (e) => setQuery(e.target.value),
438
+ "aria-label": t("dataEntry.recordPicker.search"),
439
+ placeholder: t("dataEntry.recordPicker.searchPlaceholder")
440
+ }
441
+ ),
442
+ filters?.length ? /* @__PURE__ */ jsx(
443
+ Flex,
444
+ {
445
+ direction: "row",
446
+ gap: "sm",
447
+ wrap: true,
448
+ role: "group",
449
+ "aria-label": t("dataEntry.recordPicker.filters"),
450
+ children: filters.map((f) => /* @__PURE__ */ jsx(
451
+ Select,
452
+ {
453
+ size: "sm",
454
+ "aria-label": f.label,
455
+ placeholder: f.label,
456
+ value: filterValues[f.name] ?? "",
457
+ onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
458
+ options: [
459
+ { value: "", label: t("dataEntry.recordPicker.allFilter") },
460
+ ...f.options
461
+ ]
462
+ },
463
+ f.name
464
+ ))
465
+ }
466
+ ) : null,
467
+ /* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
468
+ EmptyState,
469
+ {
470
+ variant: "compact",
471
+ tone: "destructive",
472
+ title: t("dataEntry.recordPicker.error"),
473
+ action: /* @__PURE__ */ jsx(
474
+ Button,
475
+ {
476
+ variant: "outline",
477
+ size: "sm",
478
+ onClick: () => void load(query, filterValues),
479
+ children: t("dataEntry.recordPicker.more")
480
+ }
481
+ )
482
+ }
483
+ ) : status === "loading" ? /* @__PURE__ */ jsx("div", { role: "status", className: "ui-record-picker-status", children: t("dataEntry.recordPicker.loading") }) : visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "compact", title: t("dataEntry.recordPicker.empty") }) : groups.map(([heading, items]) => {
484
+ const rendered = items.map((o) => {
485
+ const picked = (isMultiple ? draft : selected).includes(o.value);
486
+ return /* @__PURE__ */ jsxs(
487
+ "button",
488
+ {
489
+ type: "button",
490
+ role: "option",
491
+ "aria-selected": picked,
492
+ "data-picked": picked ? "" : void 0,
493
+ disabled: o.disabled,
494
+ className: "ui-record-picker-option",
495
+ onClick: () => toggle(o.value),
496
+ children: [
497
+ /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: picked ? /* @__PURE__ */ jsx(Check, {}) : null }),
498
+ o.icon,
499
+ /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
500
+ o.label,
501
+ o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
502
+ ] })
503
+ ]
504
+ },
505
+ o.value
506
+ );
507
+ });
508
+ return heading ? /* @__PURE__ */ jsx(CommandGroup, { heading, children: rendered }, heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rendered }, "__ungrouped");
509
+ }) }),
510
+ cursor !== void 0 && status !== "loading" ? /* @__PURE__ */ jsx(
289
511
  Button,
290
512
  {
291
513
  variant: "outline",
292
514
  size: "sm",
293
- onClick: () => void load(query, filterValues),
515
+ onClick: () => void load(query, filterValues, cursor),
294
516
  children: t("dataEntry.recordPicker.more")
295
517
  }
296
- )
297
- }
298
- ) : status === "loading" ? /* @__PURE__ */ jsx("div", { role: "status", className: "ui-record-picker-status", children: t("dataEntry.recordPicker.loading") }) : visible.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { variant: "compact", title: t("dataEntry.recordPicker.empty") }) : groups.map(([heading, items]) => {
299
- const rendered = items.map((o) => {
300
- const picked = (isMultiple ? draft : selected).includes(o.value);
301
- return /* @__PURE__ */ jsxs(
302
- "button",
518
+ ) : null
519
+ ] }) }),
520
+ isMultiple ? /* @__PURE__ */ jsxs(DialogFooter, { children: [
521
+ /* @__PURE__ */ jsx(Text, { as: "span", size: "xs", tone: "muted", className: "me-auto", children: t("dataEntry.recordPicker.selected", { count: draft.length }) }),
522
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setDraft([]), children: [
523
+ /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
524
+ t("dataEntry.recordPicker.clear")
525
+ ] }),
526
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setOpen(false), children: t("dataEntry.recordPicker.cancel") }),
527
+ /* @__PURE__ */ jsx(
528
+ Button,
303
529
  {
304
- type: "button",
305
- role: "option",
306
- "aria-selected": picked,
307
- "data-picked": picked ? "" : void 0,
308
- disabled: o.disabled,
309
- className: "ui-record-picker-option",
310
- onClick: () => toggle(o.value),
311
- children: [
312
- /* @__PURE__ */ jsx("span", { className: "ui-record-picker-tick", "aria-hidden": "true", children: picked ? /* @__PURE__ */ jsx(Check, {}) : null }),
313
- o.icon,
314
- /* @__PURE__ */ jsxs("span", { className: "ui-record-picker-option-label", children: [
315
- o.label,
316
- o.sublabel ? /* @__PURE__ */ jsx(Text, { as: "span", size: "2xs", tone: "muted", children: o.sublabel }) : null
317
- ] })
318
- ]
319
- },
320
- o.value
321
- );
322
- });
323
- return heading ? /* @__PURE__ */ jsx(CommandGroup, { heading, children: rendered }, heading) : /* @__PURE__ */ jsx(React.Fragment, { children: rendered }, "__ungrouped");
324
- }) }),
325
- cursor !== void 0 && status !== "loading" ? /* @__PURE__ */ jsx(
326
- Button,
327
- {
328
- variant: "outline",
329
- size: "sm",
330
- onClick: () => void load(query, filterValues, cursor),
331
- children: t("dataEntry.recordPicker.more")
332
- }
333
- ) : null
334
- ] }) }),
335
- isMultiple ? /* @__PURE__ */ jsxs(DialogFooter, { children: [
336
- /* @__PURE__ */ jsx(Text, { as: "span", size: "xs", tone: "muted", className: "me-auto", children: t("dataEntry.recordPicker.selected", { count: draft.length }) }),
337
- /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setDraft([]), children: [
338
- /* @__PURE__ */ jsx(X, { "aria-hidden": "true" }),
339
- t("dataEntry.recordPicker.clear")
340
- ] }),
341
- /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setOpen(false), children: t("dataEntry.recordPicker.cancel") }),
342
- /* @__PURE__ */ jsx(
343
- Button,
344
- {
345
- size: "sm",
346
- onClick: () => {
347
- commit(draft);
348
- setOpen(false);
349
- },
350
- children: t("dataEntry.recordPicker.confirm")
351
- }
352
- )
353
- ] }) : null
354
- ] }) })
530
+ size: "sm",
531
+ onClick: () => {
532
+ commit(draft);
533
+ setOpen(false);
534
+ },
535
+ children: t("dataEntry.recordPicker.confirm")
536
+ }
537
+ )
538
+ ] }) : null
539
+ ] })
540
+ }
541
+ )
355
542
  ] });
356
543
  });
357
544
  export {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "30.5.3",
3
+ "version": "30.7.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,
@@ -206,7 +206,9 @@
206
206
  "cancel": "Cancel",
207
207
  "clear": "Clear selection",
208
208
  "more": "Load more",
209
- "openDialog": "Choose from list"
209
+ "openDialog": "Choose from list",
210
+ "openSearch": "Search",
211
+ "suggestions": "Suggestions"
210
212
  }
211
213
  },
212
214
  "feedback": {
@@ -202,7 +202,9 @@
202
202
  "cancel": "キャンセル",
203
203
  "clear": "選択を解除",
204
204
  "more": "さらに読み込む",
205
- "openDialog": "一覧から選択"
205
+ "openDialog": "一覧から選択",
206
+ "openSearch": "検索",
207
+ "suggestions": "候補"
206
208
  }
207
209
  },
208
210
  "feedback": {
@@ -203,7 +203,9 @@
203
203
  "cancel": "Huỷ",
204
204
  "clear": "Bỏ chọn",
205
205
  "more": "Tải thêm",
206
- "openDialog": "Chọn từ danh sách"
206
+ "openDialog": "Chọn từ danh sách",
207
+ "openSearch": "Tìm kiếm",
208
+ "suggestions": "Gợi ý"
207
209
  }
208
210
  },
209
211
  "feedback": {
@@ -1148,6 +1148,22 @@ export type RecordPickerFilterProp = {
1148
1148
  export type RecordPickerProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "defaultValue" | "onChange"> & {
1149
1149
  /** `single` (default) or `multiple`. Decides the value shape AND whether the dialog confirms. */
1150
1150
  mode?: "single" | "multiple";
1151
+ /**
1152
+ * HAI LỐI VÀO CÙNG LÚC, thay vì "dropdown HOẶC Dialog" (gh#944).
1153
+ *
1154
+ * `auto` (mặc định) giữ nguyên hành vi cũ: `threshold` quyết một trong hai.
1155
+ *
1156
+ * `inline` mở cả hai cùng lúc — ô gõ được có gợi ý ngay bên dưới BẤT KỂ `count`, và một nút
1157
+ * 「検索」 luôn hiện bên cạnh mở Dialog có filter + phân trang. Chữ đang gõ dở mang sang Dialog
1158
+ * làm từ khoá ban đầu.
1159
+ *
1160
+ * Vì sao nó tồn tại: consumer đo được dự án khách có vài trăm tới vài nghìn bản ghi, nên
1161
+ * `count > threshold` gần như LUÔN đúng — tức nhánh "chỉ Dialog" là nhánh người dùng gặp
1162
+ * thường xuyên nhất, và ở đó họ mất hẳn khả năng gõ. Ngưỡng trả lời đúng câu hỏi "tập lớn hay
1163
+ * nhỏ" nhưng câu hỏi thật là "người này đã biết mình tìm gì chưa": biết rồi thì gõ nhanh hơn
1164
+ * mở modal, chưa biết thì cần filter. `inline` không bắt chọn.
1165
+ */
1166
+ shape?: "auto" | "inline";
1151
1167
  value?: string | string[] | null;
1152
1168
  defaultValue?: string | string[] | null;
1153
1169
  /** Receives the shape you passed in: an array for `multiple`, a single value (or null) else. */
@@ -846,3 +846,21 @@
846
846
  min-inline-size: 0;
847
847
  }
848
848
  }
849
+
850
+ @layer components {
851
+
852
+ .ui-record-picker-inline {
853
+ inline-size: 100%;
854
+ }
855
+ .ui-record-picker-inline-input {
856
+ flex: 1 1 auto;
857
+ min-inline-size: 0;
858
+ }
859
+
860
+ .ui-record-picker-suggest {
861
+ max-block-size: var(--record-picker-suggest-max-block-size);
862
+ overflow-y: auto;
863
+ border: var(--stroke-hairline) solid hsl(var(--border));
864
+ border-radius: var(--radius-md);
865
+ }
866
+ }
@@ -4,6 +4,8 @@
4
4
 
5
5
  --record-picker-list-max-block-size: 22rem;
6
6
 
7
+ --record-picker-suggest-max-block-size: 14rem;
8
+
7
9
  --password-strength-score-font-size: var(
8
10
  --font-size-xs,
9
11
  calc(var(--font-size-base) / var(--font-size-ratio))
@@ -3,8 +3,8 @@
3
3
  > Generated by `scripts/frame-coverage.mjs` (issue #163). Do not edit by hand — run `pnpm check:frame-coverage`.
4
4
  > Standard: [FRAME-COVERAGE-STANDARD.md](./FRAME-COVERAGE-STANDARD.md). A missing dimension is **UNTESTED**, never a pass.
5
5
 
6
- - Public components: **173**
7
- - With ≥1 frame: **152** · zero-frame: **21**
6
+ - Public components: **175**
7
+ - With ≥1 frame: **152** · zero-frame: **23**
8
8
  - Fully covered (every contract axis declared): **0**
9
9
 
10
10
  ## UNTESTED — no `/frame/**` route
@@ -22,6 +22,8 @@
22
22
  - [ ] `ServiceCatalogCta`
23
23
  - [ ] `SkeletonRows`
24
24
  - [ ] `OverlayPortalProvider`
25
+ - [ ] `SkeletonDetail`
26
+ - [ ] `SkeletonStat`
25
27
  - [ ] `SkeletonArticle`
26
28
  - [ ] `SkeletonAvatar`
27
29
  - [ ] `SkeletonButton`
@@ -154,6 +156,8 @@ Legend: ✓ covered · N/A reasoned skip · · UNTESTED (blank cell = UNTESTED).
154
156
  | Avatar | ✓ | ✓ | N/A | N/A | · | · | · | · | · | · |
155
157
  | Separator | ✓ | · | N/A | N/A | · | · | · | · | · | · |
156
158
  | Skeleton | ✓ | N/A | · | N/A | · | · | · | · | · | · |
159
+ | SkeletonDetail | **✗** | N/A | N/A | N/A | · | · | · | · | · | · |
160
+ | SkeletonStat | **✗** | N/A | N/A | N/A | · | · | · | · | · | · |
157
161
  | SkeletonArticle | **✗** | N/A | · | N/A | · | · | · | · | · | · |
158
162
  | SkeletonAvatar | **✗** | ✓ | N/A | N/A | · | · | · | · | · | · |
159
163
  | SkeletonButton | **✗** | ✓ | N/A | N/A | · | · | · | · | · | · |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "30.5.3",
4
- "godxUiMcp": "30.5.3",
3
+ "version": "30.7.0",
4
+ "godxUiMcp": "30.7.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -40,17 +40,53 @@ function changedFiles() {
40
40
  return r.status === 0 ? (r.stdout ?? "") : null;
41
41
  };
42
42
 
43
- const mergeBase = run(["merge-base", "HEAD", "origin/main"]);
43
+ /*
44
+ * `origin/main` WAS HARD-CODED, AND MOST REPOS THAT RUN THIS DO NOT DEFAULT TO main (gh#948).
45
+ *
46
+ * In a repo whose default branch is `dev`, `origin/main` usually still EXISTS — stale, or
47
+ * forked long ago — so `merge-base` resolves happily to an ancient commit and the "what this
48
+ * branch changed" set quietly becomes "everything since that commit". Measured in
49
+ * godx-corebooks: a fresh branch off `origin/dev` with ZERO edits audited ~200 files and
50
+ * reported 1114 errors. Nothing was wrong with the branch; the question was.
51
+ *
52
+ * That is the worst shape a scope bug can take — not a crash, a plausible number. It blocked
53
+ * corebooks#114 (the ratchet gate) and #86, because a ratchet cannot start from a baseline
54
+ * nobody believes.
55
+ *
56
+ * Order: an explicit `--base=<ref>` wins; otherwise `origin/HEAD`, which is the symbolic ref
57
+ * git keeps for the remote's OWN default branch and is therefore right in every repo without
58
+ * naming one; `origin/main` stays last so nothing that works today stops working.
59
+ *
60
+ * Fail-closed is unchanged (gh#542): if no candidate resolves, this returns an error rather
61
+ * than an empty file list. "Could not work out the base" must never read as "nothing changed".
62
+ */
63
+ const explicitBase = args.find((a) => a.startsWith("--base="))?.slice("--base=".length);
64
+ const candidates = explicitBase ? [explicitBase] : ["origin/HEAD", "origin/main"];
65
+
66
+ let mergeBase = null;
67
+ let baseUsed = null;
68
+ for (const candidate of candidates) {
69
+ const out = run(["merge-base", "HEAD", candidate]);
70
+ if (out !== null) {
71
+ mergeBase = out;
72
+ baseUsed = candidate;
73
+ break;
74
+ }
75
+ }
76
+
44
77
  if (mergeBase === null) {
45
78
  return {
46
79
  error:
47
- "ui-audit --changed could not resolve `git merge-base HEAD origin/main`. " +
80
+ `ui-audit --changed could not resolve a base (tried: ${candidates.join(", ")}). ` +
48
81
  "Without a base there is no such thing as \u201cwhat this branch changed\u201d, and reporting a " +
49
- "clean audit from that is not a result. Fetch origin/main (a shallow clone may need " +
50
- "`git fetch --unshallow`), or pass the directories to scan instead of `--changed`.",
82
+ "clean audit from that is not a result. Fetch the default branch (a shallow clone may need " +
83
+ "`git fetch --unshallow`), pass `--base=<ref>`, or pass the directories to scan instead of " +
84
+ "`--changed`.",
51
85
  };
52
86
  }
53
87
 
88
+
89
+
54
90
  const parts = [
55
91
  run(["diff", "--name-only", "--diff-filter=ACMR", mergeBase.trim(), "--"]),
56
92
  run(["diff", "--name-only", "--diff-filter=ACMR", "--cached"]),
@@ -64,6 +100,10 @@ function changedFiles() {
64
100
  }
65
101
 
66
102
  return {
103
+ // Đi kèm danh sách file, và được in ra: một base SAI không làm chương trình chết, nó cho ra
104
+ // một con số hợp lý (1114 lỗi trên nhánh chưa sửa gì). Cách duy nhất để người đọc phát hiện
105
+ // là thấy nó đã so với cái gì.
106
+ base: baseUsed,
67
107
  files: [
68
108
  ...new Set(
69
109
  parts
@@ -96,13 +136,55 @@ if (changed?.error) {
96
136
  }
97
137
  process.exit(2);
98
138
  }
139
+ /*
140
+ * ONE TERRITORY, READ BY BOTH PATHS (gh#949).
141
+ *
142
+ * The scan roots used to be spelled inline here, so only the DEFAULT path knew them and
143
+ * `--changed` scanned whatever git happened to name. On the same commit, same working tree:
144
+ *
145
+ * pnpm run audit -> 0 error
146
+ * node scripts/ui-audit.mjs --changed -> 3 error
147
+ *
148
+ * All three were in `mcp/src/data/components.ts`, and all three were the catalog's own PROSE —
149
+ * `Text`'s description quotes `<span className="text-[13px]">` as the anti-example the component
150
+ * exists to replace. A guard that reports its own teaching material is a guard people switch off.
151
+ *
152
+ * The fix is NOT to strip backtick spans from the corpus, which was the first idea: in a `.tsx`
153
+ * a backtick is a TEMPLATE LITERAL, i.e. real code — `className={`text-[13px]`}` appears in this
154
+ * repo today, and blinding the audit to it would trade three false positives for real misses.
155
+ *
156
+ * Nor is it a per-directory exclusion. The audit's subject is UI CODE, and in SELF mode this
157
+ * repo's UI code is `src/` and `docs/` — `mcp/src/data` renders nothing and holds no JSX (0 `.tsx`
158
+ * files, measured). So the roots are the territory, and `--changed` now narrows to it rather than
159
+ * having its own opinion. Add a root here and BOTH paths gain it; that is the property the split
160
+ * spelling could not have.
161
+ *
162
+ * CONSUMER mode is deliberately NOT narrowed. There the roots are a GUESS at a Laravel layout,
163
+ * and an app keeping its components somewhere else would get "nothing scanned" from a filter that
164
+ * is only right about this repo. `--changed` seeing the real files is strictly better there.
165
+ */
166
+ const SELF_SCAN_ROOTS = ["src", "docs"];
167
+ const CONSUMER_SCAN_ROOTS = [
168
+ "resources/js/components",
169
+ "resources/js/pages",
170
+ "resources/js/layouts",
171
+ ];
172
+ const withinSelfRoots = (file) =>
173
+ SELF_SCAN_ROOTS.some((root) => file === root || file.startsWith(`${root}/`));
174
+
175
+ /** Named so the summary can say how many files `--changed` handed over and the roots declined. */
176
+ const changedOutsideRoots =
177
+ CHANGED && SELF && !dirArgs.length ? changed.files.filter((f) => !withinSelfRoots(f)) : [];
178
+
99
179
  const SCAN_DIRS = CHANGED
100
- ? changed.files
180
+ ? SELF && !dirArgs.length
181
+ ? changed.files.filter(withinSelfRoots)
182
+ : changed.files
101
183
  : dirArgs.length
102
184
  ? dirArgs
103
185
  : SELF
104
- ? ["src", "docs"]
105
- : ["resources/js/components", "resources/js/pages", "resources/js/layouts"];
186
+ ? SELF_SCAN_ROOTS
187
+ : CONSUMER_SCAN_ROOTS;
106
188
 
107
189
  const PALETTE =
108
190
  "red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|gray|grey|slate|zinc|neutral|stone";
@@ -1850,7 +1932,15 @@ if (changedNoFiles && findings.length === 0) {
1850
1932
  JSON.stringify({ summary: { errors: 0, warnings: 0 }, findings: [] }, null, 2) + "\n",
1851
1933
  );
1852
1934
  } else if (!quiet) {
1853
- console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
1935
+ // "Nothing changed" and "everything that changed is outside the territory" are two
1936
+ // different facts, and reading the first when the second is true is how someone concludes
1937
+ // their file was audited (gh#949).
1938
+ console.log(
1939
+ changedOutsideRoots.length > 0
1940
+ ? `✓ ui-audit --changed: nhánh này không đổi file .tsx/.jsx nào trong ` +
1941
+ `[${SELF_SCAN_ROOTS.join(", ")}] (${changedOutsideRoots.length} file đổi ở ngoài đó).`
1942
+ : "✓ ui-audit --changed: no .tsx/.jsx changed on this branch.",
1943
+ );
1854
1944
  }
1855
1945
  process.exit(0);
1856
1946
  }
@@ -1899,6 +1989,19 @@ if (filesScanned === 0 && !changedNoFiles) {
1899
1989
  if (f.standard) console.log(` ${C.dim}standard: ${f.standard}${C.reset}`);
1900
1990
  console.log(` ${C.dim}${f.snippet}${C.reset}`);
1901
1991
  }
1992
+ // Dưới `--changed`, NÓI RA base đã so. Một base sai không làm chương trình chết — nó cho ra một
1993
+ // con số hợp lý, và đó là thứ duy nhất người đọc có thể dùng để nghi ngờ (gh#948).
1994
+ if (CHANGED && changed?.base) {
1995
+ console.log(`${C.dim}so với: ${changed.base}${C.reset}`);
1996
+ }
1997
+ // Bỏ file trong im lặng là cách một gate thu hẹp dần mà không ai thấy (cùng lý do với dòng
1998
+ // `so với:` ngay trên). Nói ra số file và lãnh thổ đã dùng.
1999
+ if (changedOutsideRoots.length > 0 && !quiet) {
2000
+ console.log(
2001
+ `${C.dim}ngoài lãnh thổ [${SELF_SCAN_ROOTS.join(", ")}], không quét: ` +
2002
+ `${changedOutsideRoots.length} file${C.reset}`,
2003
+ );
2004
+ }
1902
2005
  console.log(
1903
2006
  `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset}` +
1904
2007
  (scannedFiles.length > 0