@suphark/ui 0.8.0 → 0.9.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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/components/shared/rule-builder.tsx +465 -0
- package/src/index.ts +14 -0
- package/src/styles.css +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.9.0] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`RuleBuilder<F extends string>`** (barrel, สเปกจาก Pi Files — แทน rule-builder ชั่วคราว 390 บรรทัดของกฎ storage/lifecycle) — ตัวสร้างเงื่อนไขแบบต้นไม้ AND/OR/NOT ซ้อนได้ generic ไม่ผูก schema: แอปส่ง `fields[{ key, label, operators[{ value, label }], defaultLeaf(), renderValue({ leaf, onChange, disabled }), describe?, group?, disabled?: boolean | string }]` (ตัวแก้ค่าเป็นของแอป — ToggleGroup/Input/number+หน่วย), component จัดโครง: กลุ่ม = กรอบ (root border ปกติ, ลูก border-dashed bg-muted/30) + ToggleGroup and/or + ปุ่ม "เงื่อนไข"/"กลุ่มย่อย" (ซ่อนเมื่อถึง `maxDepth` ค่าเริ่มต้น 3 รวม root; กลุ่มใหม่สลับ op จากแม่), leaf = grid `[ฟิลด์ 11rem | operator (เมื่อ >1) | ค่า | NOT]` มือถือเรียงลง (เปลี่ยนฟิลด์ → `defaultLeaf()` ของฟิลด์ใหม่), NOT = กรอบ border-destructive/40 + ป้าย "ไม่ใช่" + ปุ่ม "เอา NOT ออก" (`allowNot`), ปุ่มลบทุก item; ฟิลด์ใช้ `NativeSelect` (`label` เป็น string, `group` = optgroup, `disabled` string = title เหตุผล, ฟิลด์ที่เลือกอยู่แล้วไม่ถูกปิด), key ของ item คงที่ต่อ object (WeakMap) แก้ item หนึ่งไม่ re-mount พี่น้อง, `onChange` ต้นไม้ใหม่ทั้งก้อน immutable, `role=group` aria-label "กลุ่ม AND ระดับ n", `disabled` ทั้ง builder, `showSummary` ประโยคสรุปใต้ builder, `defaultField`, `language`/`text` (and/or/not/wrapNot/addCondition/addGroup/removeCondition/removeNot/emptyAnd/emptyOr/summary/field/operator/group()/andJoin/orJoin/notPrefix/unknownField()); data-slot `rule-builder`/`rule-group[data-op][data-depth]`/`rule-item`/`rule-leaf`/`rule-not`/`rule-builder-summary`
|
|
10
|
+
- types `RuleLeaf<F>`/`RuleGroup<F>`/`RuleNot<F>`/`RuleCondition<F>`/`RuleFieldDef<F>`/`RuleBuilderProps<F>`/`RuleBuilderText`; helpers **`describeCondition(cond, fields, text?, language?)`** (AND " และ ", OR " หรือ ", NOT "ไม่ใช่ …", กลุ่มย่อย >1 ข้อครอบวงเล็บ, leaf ใช้ `describe` ของฟิลด์หรือ "label operator value", ฟิลด์ไม่รู้จัก → "ฟิลด์ไม่รู้จัก: key"), `isRuleGroup`/`isRuleNot` type guards, `emptyGroup(op = "and")`
|
|
11
|
+
- showcase: หน้า `rule-builder` (ชุดกฎ storage ตัวอย่าง + JSON สด) — 96 หน้า; tests 4 กรณี (รวม 123)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`CommandItem` ที่ active มองไม่ออกว่าตัวไหน** (รายงานจาก Pi Done ใน dark mode): shadcn ใช้ `data-selected:bg-muted` แต่ Tailwind `data-selected:` = `[data-selected]` (มีแอตทริบิวต์) ขณะที่ cmdk ใส่ `data-selected="false"` ให้รายการอื่นด้วย → ทุกแถวได้ bg-muted เท่ากัน; `styles.css` override `[data-slot=command-item][data-selected="true"]` เป็น `color-mix(muted, foreground 8%)` (dark 14%) — ไม่แตะ `command.tsx` ของ shadcn (CommandPalette, Combobox, SearchableSelect ได้ผลด้วย)
|
|
16
|
+
|
|
5
17
|
## [0.8.0] — 2026-09-21
|
|
6
18
|
|
|
7
19
|
รอบ feedback หลังทุกแอปย้ายมา 0.7.0 ครบ 6/6 — สเปกจาก Pi Done (ColorPicker, CommandPalette client-side), Pi Mail (ActionForm), Pi Files (file explorer 4 ตัว) ทำรวดเดียว; minor ทั้งหมด ไม่เปลี่ยน API เดิม
|
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ import { ThemeProvider, Toaster } from "@suphark/ui";
|
|
|
60
60
|
```ts
|
|
61
61
|
// ทุกอย่างที่ไม่พึ่ง Next — primitives, shared, theme, hooks, cn
|
|
62
62
|
import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, ConfirmDialog, useConfirm, cn } from "@suphark/ui";
|
|
63
|
-
// file explorer / picker: TreeView, Dropzone, UploadList, FileTypeIcon, ColorPicker, IconPicker, CalendarMonthGrid ก็อยู่ barrel
|
|
63
|
+
// file explorer / picker / rule: TreeView, Dropzone, UploadList, FileTypeIcon, ColorPicker, IconPicker, CalendarMonthGrid, RuleBuilder ก็อยู่ barrel
|
|
64
64
|
|
|
65
65
|
// ของที่ใช้ next/navigation หรือ next/link + โครงแอป
|
|
66
66
|
import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
NativeSelect,
|
|
6
|
+
NativeSelectOptGroup,
|
|
7
|
+
NativeSelectOption,
|
|
8
|
+
} from "@suphark/ui/components/ui/native-select";
|
|
9
|
+
import { ToggleGroup, ToggleGroupItem } from "@suphark/ui/components/ui/toggle-group";
|
|
10
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
11
|
+
import { Plus, X } from "lucide-react";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
// ---------- ชนิดข้อมูล (แอปเก็บ/ส่ง server ในรูปนี้ได้ตรง ๆ) ----------
|
|
15
|
+
|
|
16
|
+
export type RuleLeaf<F extends string = string> = { field: F; operator: string; value: unknown };
|
|
17
|
+
export type RuleGroup<F extends string = string> = { op: "and" | "or"; items: RuleCondition<F>[] };
|
|
18
|
+
export type RuleNot<F extends string = string> = { op: "not"; item: RuleCondition<F> };
|
|
19
|
+
export type RuleCondition<F extends string = string> = RuleLeaf<F> | RuleGroup<F> | RuleNot<F>;
|
|
20
|
+
|
|
21
|
+
export function isRuleGroup<F extends string>(c: RuleCondition<F>): c is RuleGroup<F> {
|
|
22
|
+
return "op" in c && (c.op === "and" || c.op === "or");
|
|
23
|
+
}
|
|
24
|
+
export function isRuleNot<F extends string>(c: RuleCondition<F>): c is RuleNot<F> {
|
|
25
|
+
return "op" in c && c.op === "not";
|
|
26
|
+
}
|
|
27
|
+
export function emptyGroup<F extends string = string>(op: "and" | "or" = "and"): RuleGroup<F> {
|
|
28
|
+
return { op, items: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RuleFieldDef<F extends string = string> {
|
|
32
|
+
key: F;
|
|
33
|
+
/** ชื่อฟิลด์ (ข้อความ — แสดงใน `<select>` จึงเป็น string) */
|
|
34
|
+
label: string;
|
|
35
|
+
/** ตัวดำเนินการที่ฟิลด์นี้รองรับ — 1 ตัว = ซ่อน select operator */
|
|
36
|
+
operators: ReadonlyArray<{ value: string; label: string }>;
|
|
37
|
+
/** ค่าเริ่มต้นเมื่อเลือกฟิลด์นี้ (ทั้ง operator + value) */
|
|
38
|
+
defaultLeaf: () => RuleLeaf<F>;
|
|
39
|
+
/** ตัวแก้ค่า — แอปส่ง component เอง (ToggleGroup / Input / number+หน่วย …) */
|
|
40
|
+
renderValue: (props: {
|
|
41
|
+
leaf: RuleLeaf<F>;
|
|
42
|
+
onChange: (leaf: RuleLeaf<F>) => void;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
}) => ReactNode;
|
|
45
|
+
/** ประโยคสรุปของ leaf (ใช้ใน `describeCondition`) — ไม่ส่ง = "label operator value" */
|
|
46
|
+
describe?: (leaf: RuleLeaf<F>) => string;
|
|
47
|
+
/** จัดกลุ่มใน select ฟิลด์ (optgroup) */
|
|
48
|
+
group?: string;
|
|
49
|
+
/** true = เลือกไม่ได้; string = เหตุผล (แสดงเป็น title ของตัวเลือก) */
|
|
50
|
+
disabled?: boolean | string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RuleBuilderText {
|
|
54
|
+
and: string;
|
|
55
|
+
or: string;
|
|
56
|
+
/** ป้ายกรอบ NOT */
|
|
57
|
+
not: string;
|
|
58
|
+
/** ปุ่มครอบ leaf ด้วย NOT */
|
|
59
|
+
wrapNot: string;
|
|
60
|
+
addCondition: string;
|
|
61
|
+
addGroup: string;
|
|
62
|
+
removeCondition: string;
|
|
63
|
+
removeNot: string;
|
|
64
|
+
emptyAnd: string;
|
|
65
|
+
emptyOr: string;
|
|
66
|
+
summary: string;
|
|
67
|
+
field: string;
|
|
68
|
+
operator: string;
|
|
69
|
+
/** aria-label ของกลุ่ม */
|
|
70
|
+
group: (op: "and" | "or", level: number) => string;
|
|
71
|
+
/** ตัวเชื่อมในประโยคสรุป */
|
|
72
|
+
andJoin: string;
|
|
73
|
+
orJoin: string;
|
|
74
|
+
notPrefix: string;
|
|
75
|
+
unknownField: (key: string) => string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TEXT: Record<"th" | "en", RuleBuilderText> = {
|
|
79
|
+
th: {
|
|
80
|
+
and: "ต้องตรงทุกข้อ (AND)",
|
|
81
|
+
or: "ตรงข้อใดข้อหนึ่ง (OR)",
|
|
82
|
+
not: "ไม่ใช่",
|
|
83
|
+
wrapNot: "NOT",
|
|
84
|
+
addCondition: "เงื่อนไข",
|
|
85
|
+
addGroup: "กลุ่มย่อย",
|
|
86
|
+
removeCondition: "ลบเงื่อนไข",
|
|
87
|
+
removeNot: "เอา NOT ออก",
|
|
88
|
+
emptyAnd: "ยังไม่มีเงื่อนไข = ตรงทุกรายการ",
|
|
89
|
+
emptyOr: "ยังไม่มีเงื่อนไข = ไม่ตรงอะไรเลย",
|
|
90
|
+
summary: "สรุป",
|
|
91
|
+
field: "ฟิลด์",
|
|
92
|
+
operator: "ตัวดำเนินการ",
|
|
93
|
+
group: (op, level) => `กลุ่ม ${op.toUpperCase()} ระดับ ${level}`,
|
|
94
|
+
andJoin: " และ ",
|
|
95
|
+
orJoin: " หรือ ",
|
|
96
|
+
notPrefix: "ไม่ใช่ ",
|
|
97
|
+
unknownField: (key) => `ฟิลด์ไม่รู้จัก: ${key}`,
|
|
98
|
+
},
|
|
99
|
+
en: {
|
|
100
|
+
and: "Match all (AND)",
|
|
101
|
+
or: "Match any (OR)",
|
|
102
|
+
not: "NOT",
|
|
103
|
+
wrapNot: "NOT",
|
|
104
|
+
addCondition: "Condition",
|
|
105
|
+
addGroup: "Subgroup",
|
|
106
|
+
removeCondition: "Remove condition",
|
|
107
|
+
removeNot: "Remove NOT",
|
|
108
|
+
emptyAnd: "No conditions = matches everything",
|
|
109
|
+
emptyOr: "No conditions = matches nothing",
|
|
110
|
+
summary: "Summary",
|
|
111
|
+
field: "Field",
|
|
112
|
+
operator: "Operator",
|
|
113
|
+
group: (op, level) => `${op.toUpperCase()} group level ${level}`,
|
|
114
|
+
andJoin: " and ",
|
|
115
|
+
orJoin: " or ",
|
|
116
|
+
notPrefix: "not ",
|
|
117
|
+
unknownField: (key) => `Unknown field: ${key}`,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
function resolveText(language: "th" | "en", text?: Partial<RuleBuilderText>): RuleBuilderText {
|
|
122
|
+
return { ...TEXT[language], ...text };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** ประโยคสรุปของเงื่อนไข: AND " และ ", OR " หรือ ", NOT "ไม่ใช่ …", กลุ่มย่อยที่มี >1 ข้อครอบวงเล็บ */
|
|
126
|
+
export function describeCondition<F extends string>(
|
|
127
|
+
cond: RuleCondition<F>,
|
|
128
|
+
fields: ReadonlyArray<RuleFieldDef<F>>,
|
|
129
|
+
text?: Partial<RuleBuilderText>,
|
|
130
|
+
language: "th" | "en" = "th",
|
|
131
|
+
): string {
|
|
132
|
+
const t = resolveText(language, text);
|
|
133
|
+
const walk = (c: RuleCondition<F>, nested: boolean): string => {
|
|
134
|
+
if (isRuleGroup(c)) {
|
|
135
|
+
if (c.items.length === 0) return c.op === "and" ? t.emptyAnd : t.emptyOr;
|
|
136
|
+
const parts = c.items.map((i) => walk(i, true));
|
|
137
|
+
const s = parts.join(c.op === "and" ? t.andJoin : t.orJoin);
|
|
138
|
+
return nested && parts.length > 1 ? `(${s})` : s;
|
|
139
|
+
}
|
|
140
|
+
if (isRuleNot(c)) return `${t.notPrefix}${walk(c.item, true)}`;
|
|
141
|
+
const def = fields.find((f) => f.key === c.field);
|
|
142
|
+
if (!def) return t.unknownField(c.field);
|
|
143
|
+
if (def.describe) return def.describe(c);
|
|
144
|
+
const op = def.operators.find((o) => o.value === c.operator)?.label ?? c.operator;
|
|
145
|
+
const v = Array.isArray(c.value) ? c.value.join("/") : String(c.value ?? "");
|
|
146
|
+
return `${def.label} ${op} ${v}`.trim();
|
|
147
|
+
};
|
|
148
|
+
return walk(cond, false);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// key คงที่ต่อ object ของเงื่อนไข — แก้ item หนึ่งไม่ทำให้พี่น้อง re-mount (ไม่ใช้ index เป็น key)
|
|
152
|
+
const keys = new WeakMap<object, string>();
|
|
153
|
+
let keySeq = 0;
|
|
154
|
+
function keyOf(item: object): string {
|
|
155
|
+
let k = keys.get(item);
|
|
156
|
+
if (!k) {
|
|
157
|
+
k = `rule-${++keySeq}`;
|
|
158
|
+
keys.set(item, k);
|
|
159
|
+
}
|
|
160
|
+
return k;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface RuleBuilderProps<F extends string = string> {
|
|
164
|
+
value: RuleCondition<F>;
|
|
165
|
+
/** ส่งต้นไม้ใหม่ทั้งก้อน (immutable) */
|
|
166
|
+
onChange: (next: RuleCondition<F>) => void;
|
|
167
|
+
fields: ReadonlyArray<RuleFieldDef<F>>;
|
|
168
|
+
/** จำนวนระดับกลุ่มสูงสุดรวม root (ค่าเริ่มต้น 3 = root + ซ้อนได้อีก 2) */
|
|
169
|
+
maxDepth?: number;
|
|
170
|
+
/** อนุญาต NOT (ค่าเริ่มต้น true) */
|
|
171
|
+
allowNot?: boolean;
|
|
172
|
+
/** ฟิลด์เริ่มต้นเมื่อกด "เพิ่มเงื่อนไข" (ค่าเริ่มต้น ฟิลด์แรกที่ไม่ disabled) */
|
|
173
|
+
defaultField?: F;
|
|
174
|
+
disabled?: boolean;
|
|
175
|
+
/** แสดงประโยคสรุปใต้ builder */
|
|
176
|
+
showSummary?: boolean;
|
|
177
|
+
language?: "th" | "en";
|
|
178
|
+
text?: Partial<RuleBuilderText>;
|
|
179
|
+
className?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface Ctx<F extends string> {
|
|
183
|
+
fields: ReadonlyArray<RuleFieldDef<F>>;
|
|
184
|
+
text: RuleBuilderText;
|
|
185
|
+
maxDepth: number;
|
|
186
|
+
allowNot: boolean;
|
|
187
|
+
disabled: boolean;
|
|
188
|
+
newLeaf: () => RuleLeaf<F>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* ตัวสร้างเงื่อนไขแบบต้นไม้ AND/OR/NOT ซ้อนได้ (rule builder) — แอปกำหนดฟิลด์/ตัวดำเนินการ/ตัวแก้ค่าเอง
|
|
193
|
+
* ผ่าน `fields`; component จัดโครง กลุ่ม ปุ่ม การเยื้อง และ a11y — ยกจาก Pi Files (กฎ storage/lifecycle)
|
|
194
|
+
*
|
|
195
|
+
* <RuleBuilder value={cond} onChange={setCond} fields={FIELDS} maxDepth={3} showSummary />
|
|
196
|
+
*/
|
|
197
|
+
export function RuleBuilder<F extends string = string>({
|
|
198
|
+
value,
|
|
199
|
+
onChange,
|
|
200
|
+
fields,
|
|
201
|
+
maxDepth = 3,
|
|
202
|
+
allowNot = true,
|
|
203
|
+
defaultField,
|
|
204
|
+
disabled = false,
|
|
205
|
+
showSummary = false,
|
|
206
|
+
language = "th",
|
|
207
|
+
text: textProp,
|
|
208
|
+
className,
|
|
209
|
+
}: RuleBuilderProps<F>) {
|
|
210
|
+
const text = resolveText(language, textProp);
|
|
211
|
+
const firstField =
|
|
212
|
+
fields.find((f) => f.key === defaultField) ?? fields.find((f) => !f.disabled) ?? fields[0];
|
|
213
|
+
const ctx: Ctx<F> = {
|
|
214
|
+
fields,
|
|
215
|
+
text,
|
|
216
|
+
maxDepth,
|
|
217
|
+
allowNot,
|
|
218
|
+
disabled,
|
|
219
|
+
newLeaf: () =>
|
|
220
|
+
firstField
|
|
221
|
+
? firstField.defaultLeaf()
|
|
222
|
+
: ({ field: "" as F, operator: "", value: null } as RuleLeaf<F>),
|
|
223
|
+
};
|
|
224
|
+
return (
|
|
225
|
+
<div data-slot="rule-builder" className={cn("flex flex-col gap-2", className)}>
|
|
226
|
+
<ConditionNode value={value} onChange={onChange} ctx={ctx} depth={0} />
|
|
227
|
+
{showSummary && (
|
|
228
|
+
<p className="text-muted-foreground text-xs" data-slot="rule-builder-summary">
|
|
229
|
+
<span className="font-medium">{text.summary}: </span>
|
|
230
|
+
{describeCondition(value, fields, textProp, language)}
|
|
231
|
+
</p>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function ConditionNode<F extends string>({
|
|
238
|
+
value,
|
|
239
|
+
onChange,
|
|
240
|
+
ctx,
|
|
241
|
+
depth,
|
|
242
|
+
}: {
|
|
243
|
+
value: RuleCondition<F>;
|
|
244
|
+
onChange: (next: RuleCondition<F>) => void;
|
|
245
|
+
ctx: Ctx<F>;
|
|
246
|
+
depth: number;
|
|
247
|
+
}) {
|
|
248
|
+
const { text, disabled } = ctx;
|
|
249
|
+
if (isRuleGroup(value)) {
|
|
250
|
+
const canNest = depth < ctx.maxDepth - 1;
|
|
251
|
+
return (
|
|
252
|
+
<div
|
|
253
|
+
role="group"
|
|
254
|
+
aria-label={text.group(value.op, depth + 1)}
|
|
255
|
+
data-slot="rule-group"
|
|
256
|
+
data-op={value.op}
|
|
257
|
+
data-depth={depth}
|
|
258
|
+
className={cn(
|
|
259
|
+
"flex flex-col gap-2 rounded-lg border p-2",
|
|
260
|
+
depth > 0 && "border-dashed bg-muted/30",
|
|
261
|
+
)}
|
|
262
|
+
>
|
|
263
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
264
|
+
<ToggleGroup
|
|
265
|
+
value={[value.op]}
|
|
266
|
+
onValueChange={(v) => {
|
|
267
|
+
const op = (v as Array<"and" | "or">)[0];
|
|
268
|
+
if (op && op !== value.op) onChange({ ...value, op });
|
|
269
|
+
}}
|
|
270
|
+
variant="outline"
|
|
271
|
+
size="sm"
|
|
272
|
+
disabled={disabled}
|
|
273
|
+
aria-label={text.operator}
|
|
274
|
+
>
|
|
275
|
+
<ToggleGroupItem value="and">{text.and}</ToggleGroupItem>
|
|
276
|
+
<ToggleGroupItem value="or">{text.or}</ToggleGroupItem>
|
|
277
|
+
</ToggleGroup>
|
|
278
|
+
<div className="ml-auto flex gap-1">
|
|
279
|
+
<Button
|
|
280
|
+
type="button"
|
|
281
|
+
size="xs"
|
|
282
|
+
variant="outline"
|
|
283
|
+
disabled={disabled}
|
|
284
|
+
onClick={() => onChange({ ...value, items: [...value.items, ctx.newLeaf()] })}
|
|
285
|
+
>
|
|
286
|
+
<Plus />
|
|
287
|
+
{text.addCondition}
|
|
288
|
+
</Button>
|
|
289
|
+
{canNest && (
|
|
290
|
+
<Button
|
|
291
|
+
type="button"
|
|
292
|
+
size="xs"
|
|
293
|
+
variant="ghost"
|
|
294
|
+
disabled={disabled}
|
|
295
|
+
onClick={() =>
|
|
296
|
+
onChange({
|
|
297
|
+
...value,
|
|
298
|
+
// กลุ่มใหม่สลับ op จากแม่ (แม่ AND → ลูก OR) — เคสที่พบบ่อยสุด
|
|
299
|
+
items: [...value.items, emptyGroup<F>(value.op === "and" ? "or" : "and")],
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
>
|
|
303
|
+
<Plus />
|
|
304
|
+
{text.addGroup}
|
|
305
|
+
</Button>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
{value.items.length === 0 && (
|
|
310
|
+
<p className="text-muted-foreground text-xs">
|
|
311
|
+
{value.op === "and" ? text.emptyAnd : text.emptyOr}
|
|
312
|
+
</p>
|
|
313
|
+
)}
|
|
314
|
+
{value.items.map((item, i) => (
|
|
315
|
+
<div key={keyOf(item)} className="flex items-start gap-1" data-slot="rule-item">
|
|
316
|
+
<div className="min-w-0 flex-1">
|
|
317
|
+
<ConditionNode
|
|
318
|
+
value={item}
|
|
319
|
+
onChange={(next) =>
|
|
320
|
+
onChange({ ...value, items: value.items.map((x, j) => (j === i ? next : x)) })
|
|
321
|
+
}
|
|
322
|
+
ctx={ctx}
|
|
323
|
+
depth={depth + 1}
|
|
324
|
+
/>
|
|
325
|
+
</div>
|
|
326
|
+
<Button
|
|
327
|
+
type="button"
|
|
328
|
+
size="icon-xs"
|
|
329
|
+
variant="ghost"
|
|
330
|
+
aria-label={text.removeCondition}
|
|
331
|
+
disabled={disabled}
|
|
332
|
+
onClick={() => onChange({ ...value, items: value.items.filter((_, j) => j !== i) })}
|
|
333
|
+
>
|
|
334
|
+
<X />
|
|
335
|
+
</Button>
|
|
336
|
+
</div>
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (isRuleNot(value)) {
|
|
342
|
+
return (
|
|
343
|
+
<div
|
|
344
|
+
data-slot="rule-not"
|
|
345
|
+
className="flex flex-wrap items-start gap-2 rounded-lg border border-destructive/40 p-2"
|
|
346
|
+
>
|
|
347
|
+
<span className="shrink-0 rounded bg-destructive/10 px-1.5 py-0.5 text-destructive text-xs">
|
|
348
|
+
{text.not}
|
|
349
|
+
</span>
|
|
350
|
+
<div className="min-w-0 flex-1 basis-48">
|
|
351
|
+
<ConditionNode
|
|
352
|
+
value={value.item}
|
|
353
|
+
onChange={(next) => onChange({ op: "not", item: next })}
|
|
354
|
+
ctx={ctx}
|
|
355
|
+
depth={depth}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
<Button
|
|
359
|
+
type="button"
|
|
360
|
+
size="xs"
|
|
361
|
+
variant="ghost"
|
|
362
|
+
disabled={disabled}
|
|
363
|
+
onClick={() => onChange(value.item)}
|
|
364
|
+
>
|
|
365
|
+
{text.removeNot}
|
|
366
|
+
</Button>
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return <LeafRow leaf={value} onChange={onChange} ctx={ctx} />;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function LeafRow<F extends string>({
|
|
374
|
+
leaf,
|
|
375
|
+
onChange,
|
|
376
|
+
ctx,
|
|
377
|
+
}: {
|
|
378
|
+
leaf: RuleLeaf<F>;
|
|
379
|
+
onChange: (next: RuleCondition<F>) => void;
|
|
380
|
+
ctx: Ctx<F>;
|
|
381
|
+
}) {
|
|
382
|
+
const { fields, text, disabled } = ctx;
|
|
383
|
+
const def = fields.find((f) => f.key === leaf.field);
|
|
384
|
+
const showOperator = (def?.operators.length ?? 0) > 1;
|
|
385
|
+
// optgroup ตามลำดับที่พบครั้งแรก; ฟิลด์ไม่มี group อยู่บนสุด
|
|
386
|
+
const groups = new Map<string | undefined, RuleFieldDef<F>[]>();
|
|
387
|
+
for (const f of fields) {
|
|
388
|
+
const list = groups.get(f.group) ?? [];
|
|
389
|
+
list.push(f);
|
|
390
|
+
groups.set(f.group, list);
|
|
391
|
+
}
|
|
392
|
+
const option = (f: RuleFieldDef<F>) => (
|
|
393
|
+
<NativeSelectOption
|
|
394
|
+
key={f.key}
|
|
395
|
+
value={f.key}
|
|
396
|
+
disabled={Boolean(f.disabled) && f.key !== leaf.field}
|
|
397
|
+
title={typeof f.disabled === "string" ? f.disabled : undefined}
|
|
398
|
+
>
|
|
399
|
+
{f.label}
|
|
400
|
+
</NativeSelectOption>
|
|
401
|
+
);
|
|
402
|
+
return (
|
|
403
|
+
<div
|
|
404
|
+
data-slot="rule-leaf"
|
|
405
|
+
className={cn(
|
|
406
|
+
"grid gap-2 sm:items-center",
|
|
407
|
+
showOperator
|
|
408
|
+
? "sm:grid-cols-[11rem_auto_minmax(0,1fr)_auto]"
|
|
409
|
+
: "sm:grid-cols-[11rem_minmax(0,1fr)_auto]",
|
|
410
|
+
)}
|
|
411
|
+
>
|
|
412
|
+
<NativeSelect
|
|
413
|
+
value={leaf.field}
|
|
414
|
+
disabled={disabled}
|
|
415
|
+
aria-label={text.field}
|
|
416
|
+
onChange={(e) => {
|
|
417
|
+
const next = fields.find((f) => f.key === e.target.value);
|
|
418
|
+
if (next) onChange(next.defaultLeaf());
|
|
419
|
+
}}
|
|
420
|
+
>
|
|
421
|
+
{!def && (
|
|
422
|
+
<NativeSelectOption value={leaf.field}>
|
|
423
|
+
{text.unknownField(leaf.field)}
|
|
424
|
+
</NativeSelectOption>
|
|
425
|
+
)}
|
|
426
|
+
{[...groups.entries()].map(([group, list]) =>
|
|
427
|
+
group === undefined ? (
|
|
428
|
+
list.map(option)
|
|
429
|
+
) : (
|
|
430
|
+
<NativeSelectOptGroup key={group} label={group}>
|
|
431
|
+
{list.map(option)}
|
|
432
|
+
</NativeSelectOptGroup>
|
|
433
|
+
),
|
|
434
|
+
)}
|
|
435
|
+
</NativeSelect>
|
|
436
|
+
{showOperator && def && (
|
|
437
|
+
<NativeSelect
|
|
438
|
+
value={leaf.operator}
|
|
439
|
+
disabled={disabled}
|
|
440
|
+
aria-label={text.operator}
|
|
441
|
+
onChange={(e) => onChange({ ...leaf, operator: e.target.value })}
|
|
442
|
+
>
|
|
443
|
+
{def.operators.map((o) => (
|
|
444
|
+
<NativeSelectOption key={o.value} value={o.value}>
|
|
445
|
+
{o.label}
|
|
446
|
+
</NativeSelectOption>
|
|
447
|
+
))}
|
|
448
|
+
</NativeSelect>
|
|
449
|
+
)}
|
|
450
|
+
<div className="min-w-0">{def?.renderValue({ leaf, onChange, disabled })}</div>
|
|
451
|
+
{ctx.allowNot && (
|
|
452
|
+
<Button
|
|
453
|
+
type="button"
|
|
454
|
+
size="xs"
|
|
455
|
+
variant="ghost"
|
|
456
|
+
disabled={disabled}
|
|
457
|
+
aria-label={text.wrapNot}
|
|
458
|
+
onClick={() => onChange({ op: "not", item: leaf })}
|
|
459
|
+
>
|
|
460
|
+
{text.wrapNot}
|
|
461
|
+
</Button>
|
|
462
|
+
)}
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -112,6 +112,20 @@ export { type DecimalLike, FormattedNumber } from "./components/shared/number/fo
|
|
|
112
112
|
export { PageHeader } from "./components/shared/page-header";
|
|
113
113
|
export { PageSkeleton } from "./components/shared/page-skeleton";
|
|
114
114
|
export { ProgressBar, type ProgressTone } from "./components/shared/progress-bar";
|
|
115
|
+
export {
|
|
116
|
+
describeCondition,
|
|
117
|
+
emptyGroup,
|
|
118
|
+
isRuleGroup,
|
|
119
|
+
isRuleNot,
|
|
120
|
+
RuleBuilder,
|
|
121
|
+
type RuleBuilderProps,
|
|
122
|
+
type RuleBuilderText,
|
|
123
|
+
type RuleCondition,
|
|
124
|
+
type RuleFieldDef,
|
|
125
|
+
type RuleGroup,
|
|
126
|
+
type RuleLeaf,
|
|
127
|
+
type RuleNot,
|
|
128
|
+
} from "./components/shared/rule-builder";
|
|
115
129
|
export { SecretReveal } from "./components/shared/secret-reveal";
|
|
116
130
|
export { SectionCard } from "./components/shared/section-card";
|
|
117
131
|
export { SelectionButton } from "./components/shared/selection-button";
|
package/src/styles.css
CHANGED
|
@@ -217,6 +217,19 @@ body,
|
|
|
217
217
|
font-family: var(--font-sans);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/*
|
|
221
|
+
* CommandItem ที่ active (ลูกศร/hover) มองไม่ออก: shadcn ใช้ `data-selected:bg-muted` แต่ Tailwind `data-selected:`
|
|
222
|
+
* = `[data-selected]` (มีแอตทริบิวต์) ขณะที่ cmdk ใส่ `data-selected="false"` ให้รายการอื่นด้วย → ทุกแถวได้ bg-muted
|
|
223
|
+
* เท่ากัน (รายงานจาก Pi Done ใน dark mode) → เน้นเฉพาะ "true" โดยผสม foreground; override ที่นี่แบบไม่อยู่ใน layer
|
|
224
|
+
* จึงชนะ utility โดยไม่แก้ command.tsx ของ shadcn
|
|
225
|
+
*/
|
|
226
|
+
[data-slot="command-item"][data-selected="true"] {
|
|
227
|
+
background-color: color-mix(in oklch, var(--muted), var(--foreground) 8%);
|
|
228
|
+
}
|
|
229
|
+
.dark [data-slot="command-item"][data-selected="true"] {
|
|
230
|
+
background-color: color-mix(in oklch, var(--muted), var(--foreground) 14%);
|
|
231
|
+
}
|
|
232
|
+
|
|
220
233
|
/* ผู้ใช้ตั้ง "ลดการเคลื่อนไหว" → ตัด animation/transition ของทุก component (tw-animate, animate-spin, dialog zoom …) */
|
|
221
234
|
@media (prefers-reduced-motion: reduce) {
|
|
222
235
|
*,
|