@godxjp/ui 30.5.2 → 30.6.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/agent/START-HERE.md +4 -4
- package/agent/components/RecordPicker.json +12 -1
- package/agent/components.json +12 -1
- package/agent/index.json +4 -4
- package/agent/llms.txt +5 -5
- package/agent/tokens.json +6 -0
- package/dist/components/data-entry/record-picker.d.ts +3 -1
- package/dist/components/data-entry/record-picker.js +366 -116
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +3 -1
- package/dist/i18n/messages/ja.json +3 -1
- package/dist/i18n/messages/vi.json +3 -1
- package/dist/props/components/data-entry.prop.d.ts +22 -0
- package/dist/styles/data-entry-layout.css +29 -0
- package/dist/tokens/components/data-entry.css +2 -0
- package/package.json +2 -2
- package/scripts/ui-audit.mjs +111 -8
package/agent/START-HERE.md
CHANGED
|
@@ -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.
|
|
6
|
+
**This catalog describes `@godxjp/ui` 30.6.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.
|
|
@@ -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` —
|
|
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
|
-
|
|
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` |
|
|
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.",
|
|
@@ -27,7 +33,7 @@
|
|
|
27
33
|
"type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
|
|
28
34
|
},
|
|
29
35
|
{
|
|
30
|
-
"description": "Server fetcher, debounced. `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented.",
|
|
36
|
+
"description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
|
|
31
37
|
"name": "loadOptions",
|
|
32
38
|
"type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
|
|
33
39
|
},
|
|
@@ -66,6 +72,11 @@
|
|
|
66
72
|
"name": "placeholder",
|
|
67
73
|
"type": "string"
|
|
68
74
|
},
|
|
75
|
+
{
|
|
76
|
+
"description": "Field name, normally supplied by FormField. Declared explicitly because BOTH branches must keep it: a server's 422 is pinned to `data-field`, and a consumer measured the dropdown branch dropping it while the dialog branch kept it (gh#942).",
|
|
77
|
+
"name": "data-field",
|
|
78
|
+
"type": "string"
|
|
79
|
+
},
|
|
69
80
|
{
|
|
70
81
|
"description": "Dialog heading. Defaults to the localized `dataEntry.recordPicker.dialogTitle`.",
|
|
71
82
|
"name": "dialogTitle",
|
package/agent/components.json
CHANGED
|
@@ -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.",
|
|
@@ -6013,7 +6019,7 @@
|
|
|
6013
6019
|
"type": "(SearchSelectOptionProp | SelectOptionGroupProp)[]"
|
|
6014
6020
|
},
|
|
6015
6021
|
{
|
|
6016
|
-
"description": "Server fetcher, debounced. `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented.",
|
|
6022
|
+
"description": "Server fetcher, debounced, and it runs on BOTH branches — a picker whose `count` is small still fetches from the server rather than showing an empty dropdown (gh#942). `filters` carries YOUR OWN vocabulary back (the `name` of each declared filter), so the server reads the keys it already understands instead of a shape this component invented; the dropdown branch draws no filters, so it passes an empty object. Return `nextCursor` to page: the dialog shows a Load more button and APPENDS, so nothing the user already scrolled past is lost.",
|
|
6017
6023
|
"name": "loadOptions",
|
|
6018
6024
|
"type": "(params: { query: string; filters: Record<string, string>; cursor?: string }) => Promise<{ options: SearchSelectOptionProp[]; count?: number; nextCursor?: string }>"
|
|
6019
6025
|
},
|
|
@@ -6052,6 +6058,11 @@
|
|
|
6052
6058
|
"name": "placeholder",
|
|
6053
6059
|
"type": "string"
|
|
6054
6060
|
},
|
|
6061
|
+
{
|
|
6062
|
+
"description": "Field name, normally supplied by FormField. Declared explicitly because BOTH branches must keep it: a server's 422 is pinned to `data-field`, and a consumer measured the dropdown branch dropping it while the dialog branch kept it (gh#942).",
|
|
6063
|
+
"name": "data-field",
|
|
6064
|
+
"type": "string"
|
|
6065
|
+
},
|
|
6055
6066
|
{
|
|
6056
6067
|
"description": "Dialog heading. Defaults to the localized `dataEntry.recordPicker.dialogTitle`.",
|
|
6057
6068
|
"name": "dialogTitle",
|
package/agent/index.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"components": 173,
|
|
5
5
|
"patterns": 21,
|
|
6
6
|
"rules": 50,
|
|
7
|
-
"tokens":
|
|
7
|
+
"tokens": 2074,
|
|
8
8
|
"vocabulary": 14
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
@@ -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.
|
|
51
|
+
"pinned": "https://raw.githubusercontent.com/godx-jp/godxjp-ui/v30.6.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":
|
|
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.
|
|
65
|
+
"version": "30.6.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,
|
|
4
|
-
> 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.
|
|
3
|
+
> A Japanese-enterprise React design system: 173 components, 2074 design tokens,
|
|
4
|
+
> 50 cardinal rules. This file is the entry point for AI agents. Catalog version 30.6.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.
|
|
7
|
+
(`npx @godxjp/ui-mcp@30.6.0`). It is searchable and version-locked. These files exist for agents
|
|
8
8
|
that can only fetch URLs.
|
|
9
9
|
|
|
10
10
|
## Start
|
|
@@ -18,7 +18,7 @@ that can only fetch URLs.
|
|
|
18
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`).
|
|
19
19
|
- [components/<Name>.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,
|
|
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.
|
|
29
|
+
the tag: `.../godx-jp/godxjp-ui/v30.6.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;
|
|
@@ -59,4 +60,5 @@ export declare const RecordPicker: React.ForwardRefExoticComponent<Omit<React.Bu
|
|
|
59
60
|
placeholder?: string;
|
|
60
61
|
dialogTitle?: string;
|
|
61
62
|
size?: "xs" | "sm" | "md" | "lg";
|
|
62
|
-
|
|
63
|
+
"data-field"?: string;
|
|
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,
|
|
@@ -39,6 +40,7 @@ const RecordPicker = React.forwardRef(
|
|
|
39
40
|
disabled = false,
|
|
40
41
|
size,
|
|
41
42
|
className,
|
|
43
|
+
"data-field": fieldName,
|
|
42
44
|
...props
|
|
43
45
|
}, ref) {
|
|
44
46
|
const { t } = useTranslation();
|
|
@@ -55,10 +57,44 @@ const RecordPicker = React.forwardRef(
|
|
|
55
57
|
},
|
|
56
58
|
[controlled, isMultiple, onValueChange]
|
|
57
59
|
);
|
|
60
|
+
const selectLoadOptions = React.useMemo(
|
|
61
|
+
() => loadOptions ? async ({ query, page }) => {
|
|
62
|
+
const r = await loadOptions({
|
|
63
|
+
query,
|
|
64
|
+
filters: {},
|
|
65
|
+
cursor: page > 1 ? String(page) : void 0
|
|
66
|
+
});
|
|
67
|
+
return { options: r.options, hasMore: r.nextCursor !== void 0 };
|
|
68
|
+
} : void 0,
|
|
69
|
+
[loadOptions]
|
|
70
|
+
);
|
|
58
71
|
const staticOptions = React.useMemo(
|
|
59
72
|
() => options ? normalizeSelectOptions(options) : [],
|
|
60
73
|
[options]
|
|
61
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
|
+
}
|
|
62
98
|
const total = count ?? (options ? staticOptions.length : void 0);
|
|
63
99
|
const asDialog = total === void 0 ? Boolean(loadOptions) : total > threshold;
|
|
64
100
|
if (!asDialog) {
|
|
@@ -66,6 +102,10 @@ const RecordPicker = React.forwardRef(
|
|
|
66
102
|
Select,
|
|
67
103
|
{
|
|
68
104
|
mode: isMultiple ? "multiple" : void 0,
|
|
105
|
+
loadOptions: selectLoadOptions,
|
|
106
|
+
id: props.id,
|
|
107
|
+
"data-field": fieldName,
|
|
108
|
+
"aria-describedby": props["aria-describedby"],
|
|
69
109
|
labelInValue: false,
|
|
70
110
|
showSearch: true,
|
|
71
111
|
options: withEmptyOption(options, emptyOption),
|
|
@@ -82,6 +122,7 @@ const RecordPicker = React.forwardRef(
|
|
|
82
122
|
DialogPicker,
|
|
83
123
|
{
|
|
84
124
|
ref,
|
|
125
|
+
"data-field": fieldName,
|
|
85
126
|
isMultiple,
|
|
86
127
|
selected,
|
|
87
128
|
commit,
|
|
@@ -100,6 +141,7 @@ const RecordPicker = React.forwardRef(
|
|
|
100
141
|
);
|
|
101
142
|
}
|
|
102
143
|
);
|
|
144
|
+
const INLINE_SUGGESTION_LIMIT = 8;
|
|
103
145
|
function toArray(v) {
|
|
104
146
|
if (v === null || v === void 0) return [];
|
|
105
147
|
return Array.isArray(v) ? v : [v];
|
|
@@ -108,6 +150,155 @@ function withEmptyOption(options, emptyOption) {
|
|
|
108
150
|
if (!emptyOption) return options;
|
|
109
151
|
return [{ value: emptyOption.value, label: emptyOption.label }, ...options ?? []];
|
|
110
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
|
+
});
|
|
111
302
|
const DialogPicker = React.forwardRef(function DialogPicker2({
|
|
112
303
|
isMultiple,
|
|
113
304
|
selected,
|
|
@@ -122,31 +313,41 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
|
|
|
122
313
|
disabled,
|
|
123
314
|
size,
|
|
124
315
|
className,
|
|
316
|
+
initialQuery,
|
|
317
|
+
autoOpen = false,
|
|
318
|
+
onDismiss,
|
|
125
319
|
...props
|
|
126
320
|
}, ref) {
|
|
127
321
|
const { t } = useTranslation();
|
|
128
|
-
const [open, setOpen] = React.useState(
|
|
129
|
-
const [query, setQuery] = React.useState("");
|
|
322
|
+
const [open, setOpen] = React.useState(autoOpen);
|
|
323
|
+
const [query, setQuery] = React.useState(initialQuery ?? "");
|
|
130
324
|
const [filterValues, setFilterValues] = React.useState({});
|
|
131
325
|
const [rows, setRows] = React.useState(staticOptions);
|
|
132
326
|
const [status, setStatus] = React.useState("idle");
|
|
327
|
+
const [cursor, setCursor] = React.useState(void 0);
|
|
133
328
|
const [draft, setDraft] = React.useState(selected);
|
|
134
329
|
React.useEffect(() => {
|
|
135
330
|
if (open) setDraft(selected);
|
|
136
331
|
}, [open, selected]);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
332
|
+
const labelCache = React.useRef(/* @__PURE__ */ new Map());
|
|
333
|
+
for (const o of [...selectedOptions ?? [], ...staticOptions, ...rows]) {
|
|
334
|
+
labelCache.current.set(o.value, o);
|
|
335
|
+
}
|
|
336
|
+
if (emptyOption) {
|
|
337
|
+
labelCache.current.set(emptyOption.value, {
|
|
338
|
+
value: emptyOption.value,
|
|
339
|
+
label: emptyOption.label
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
const labels = labelCache.current;
|
|
143
343
|
const load = React.useCallback(
|
|
144
|
-
async (q, f) => {
|
|
344
|
+
async (q, f, more) => {
|
|
145
345
|
if (!loadOptions) return;
|
|
146
346
|
setStatus("loading");
|
|
147
347
|
try {
|
|
148
|
-
const result = await loadOptions({ query: q, filters: f });
|
|
149
|
-
setRows(result.options);
|
|
348
|
+
const result = await loadOptions({ query: q, filters: f, cursor: more });
|
|
349
|
+
setRows((prev) => more ? [...prev, ...result.options] : result.options);
|
|
350
|
+
setCursor(result.nextCursor);
|
|
150
351
|
setStatus("idle");
|
|
151
352
|
} catch {
|
|
152
353
|
setStatus("error");
|
|
@@ -183,112 +384,161 @@ const DialogPicker = React.forwardRef(function DialogPicker2({
|
|
|
183
384
|
};
|
|
184
385
|
const chips = selected.map((v) => labels.get(v) ?? { value: v, label: v });
|
|
185
386
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
186
|
-
/* @__PURE__ */ jsxs(
|
|
187
|
-
|
|
387
|
+
autoOpen ? null : /* @__PURE__ */ jsxs(Flex, { direction: "row", gap: "xs", align: "center", className: "ui-record-picker-row", children: [
|
|
388
|
+
/* @__PURE__ */ jsxs(
|
|
389
|
+
Button,
|
|
390
|
+
{
|
|
391
|
+
ref,
|
|
392
|
+
type: "button",
|
|
393
|
+
variant: "outline",
|
|
394
|
+
size,
|
|
395
|
+
disabled,
|
|
396
|
+
"aria-haspopup": "dialog",
|
|
397
|
+
onClick: () => setOpen(true),
|
|
398
|
+
className: cn("ui-record-picker-trigger", className),
|
|
399
|
+
...props,
|
|
400
|
+
children: [
|
|
401
|
+
/* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
|
|
402
|
+
c.icon,
|
|
403
|
+
c.label
|
|
404
|
+
] }, c.value)) }) }),
|
|
405
|
+
/* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
|
|
406
|
+
]
|
|
407
|
+
}
|
|
408
|
+
),
|
|
409
|
+
!isMultiple && selected.length > 0 && !disabled ? /* @__PURE__ */ jsx(
|
|
410
|
+
Button,
|
|
411
|
+
{
|
|
412
|
+
variant: "ghost",
|
|
413
|
+
size: "icon-sm",
|
|
414
|
+
"aria-label": t("dataEntry.recordPicker.clear"),
|
|
415
|
+
onClick: () => commit([]),
|
|
416
|
+
children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
|
|
417
|
+
}
|
|
418
|
+
) : null
|
|
419
|
+
] }),
|
|
420
|
+
/* @__PURE__ */ jsx(
|
|
421
|
+
Dialog,
|
|
188
422
|
{
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
/* @__PURE__ */ jsx("span", { className: "ui-record-picker-trigger-label", children: chips.length === 0 ? /* @__PURE__ */ jsx(Text, { as: "span", size: "sm", tone: "muted", children: placeholder ?? t("dataEntry.recordPicker.placeholder") }) : /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "xs", wrap: true, align: "center", children: chips.map((c) => /* @__PURE__ */ jsxs(Badge, { variant: "secondary", as: "span", children: [
|
|
200
|
-
c.icon,
|
|
201
|
-
c.label
|
|
202
|
-
] }, c.value)) }) }),
|
|
203
|
-
/* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true" })
|
|
204
|
-
]
|
|
205
|
-
}
|
|
206
|
-
),
|
|
207
|
-
/* @__PURE__ */ jsx(Dialog, { open, onOpenChange: setOpen, children: /* @__PURE__ */ jsxs(DialogContent, { className: "ui-record-picker-dialog", children: [
|
|
208
|
-
/* @__PURE__ */ jsx(DialogHeader, { children: /* @__PURE__ */ jsx(DialogTitle, { children: dialogTitle ?? t("dataEntry.recordPicker.dialogTitle") }) }),
|
|
209
|
-
/* @__PURE__ */ jsx(DialogBody, { children: /* @__PURE__ */ jsxs(Flex, { direction: "col", gap: "md", children: [
|
|
210
|
-
/* @__PURE__ */ jsx(
|
|
211
|
-
Input,
|
|
212
|
-
{
|
|
213
|
-
autoFocus: true,
|
|
214
|
-
type: "search",
|
|
215
|
-
value: query,
|
|
216
|
-
onChange: (e) => setQuery(e.target.value),
|
|
217
|
-
"aria-label": t("dataEntry.recordPicker.search"),
|
|
218
|
-
placeholder: t("dataEntry.recordPicker.searchPlaceholder")
|
|
219
|
-
}
|
|
220
|
-
),
|
|
221
|
-
filters?.length ? /* @__PURE__ */ jsx(Flex, { direction: "row", gap: "sm", wrap: true, role: "group", "aria-label": t("dataEntry.recordPicker.filters"), children: filters.map((f) => /* @__PURE__ */ jsx(
|
|
222
|
-
Select,
|
|
223
|
-
{
|
|
224
|
-
size: "sm",
|
|
225
|
-
"aria-label": f.label,
|
|
226
|
-
placeholder: f.label,
|
|
227
|
-
value: filterValues[f.name] ?? "",
|
|
228
|
-
onValueChange: (next) => setFilterValues((prev) => ({ ...prev, [f.name]: next })),
|
|
229
|
-
options: [
|
|
230
|
-
{ value: "", label: t("dataEntry.recordPicker.allFilter") },
|
|
231
|
-
...f.options
|
|
232
|
-
]
|
|
233
|
-
},
|
|
234
|
-
f.name
|
|
235
|
-
)) }) : null,
|
|
236
|
-
/* @__PURE__ */ jsx(Command, { shouldFilter: false, split: isMultiple, className: "ui-record-picker-list", children: status === "error" ? /* @__PURE__ */ jsx(
|
|
237
|
-
EmptyState,
|
|
238
|
-
{
|
|
239
|
-
variant: "compact",
|
|
240
|
-
tone: "destructive",
|
|
241
|
-
title: t("dataEntry.recordPicker.error"),
|
|
242
|
-
action: /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => void load(query, filterValues), children: t("dataEntry.recordPicker.more") })
|
|
243
|
-
}
|
|
244
|
-
) : 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]) => {
|
|
245
|
-
const rendered = items.map((o) => {
|
|
246
|
-
const picked = (isMultiple ? draft : selected).includes(o.value);
|
|
247
|
-
return /* @__PURE__ */ jsxs(
|
|
248
|
-
"button",
|
|
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,
|
|
249
433
|
{
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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(
|
|
511
|
+
Button,
|
|
512
|
+
{
|
|
513
|
+
variant: "outline",
|
|
514
|
+
size: "sm",
|
|
515
|
+
onClick: () => void load(query, filterValues, cursor),
|
|
516
|
+
children: t("dataEntry.recordPicker.more")
|
|
517
|
+
}
|
|
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,
|
|
529
|
+
{
|
|
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
|
+
)
|
|
292
542
|
] });
|
|
293
543
|
});
|
|
294
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.
|
|
3
|
+
"version": "30.6.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,
|
|
@@ -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. */
|
|
@@ -1186,6 +1202,12 @@ export type RecordPickerProp = Omit<React.ButtonHTMLAttributes<HTMLButtonElement
|
|
|
1186
1202
|
placeholder?: string;
|
|
1187
1203
|
dialogTitle?: string;
|
|
1188
1204
|
size?: "xs" | "sm" | "md" | "lg";
|
|
1205
|
+
/**
|
|
1206
|
+
* Tên trường, do `FormField` truyền xuống. Khai tường minh vì CẢ HAI nhánh phải giữ nó: một
|
|
1207
|
+
* lỗi 422 của server bám theo `data-field`, và consumer đo được nhánh dropdown đánh rơi nó
|
|
1208
|
+
* trong khi nhánh Dialog thì giữ (gh#942).
|
|
1209
|
+
*/
|
|
1210
|
+
"data-field"?: string;
|
|
1189
1211
|
};
|
|
1190
1212
|
export type SearchSelectLoadParamsProp = {
|
|
1191
1213
|
query: string;
|
|
@@ -835,3 +835,32 @@
|
|
|
835
835
|
font-size: var(--font-size-sm);
|
|
836
836
|
}
|
|
837
837
|
}
|
|
838
|
+
|
|
839
|
+
@layer components {
|
|
840
|
+
|
|
841
|
+
.ui-record-picker-row {
|
|
842
|
+
inline-size: 100%;
|
|
843
|
+
}
|
|
844
|
+
.ui-record-picker-row > .ui-record-picker-trigger {
|
|
845
|
+
flex: 1 1 auto;
|
|
846
|
+
min-inline-size: 0;
|
|
847
|
+
}
|
|
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
|
+
}
|
package/package.json
CHANGED
package/scripts/ui-audit.mjs
CHANGED
|
@@ -40,17 +40,53 @@ function changedFiles() {
|
|
|
40
40
|
return r.status === 0 ? (r.stdout ?? "") : null;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
"`git fetch --unshallow`), or pass the directories to scan instead of
|
|
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
|
-
?
|
|
180
|
+
? SELF && !dirArgs.length
|
|
181
|
+
? changed.files.filter(withinSelfRoots)
|
|
182
|
+
: changed.files
|
|
101
183
|
: dirArgs.length
|
|
102
184
|
? dirArgs
|
|
103
185
|
: SELF
|
|
104
|
-
?
|
|
105
|
-
:
|
|
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
|
-
|
|
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
|