@suphark/ui 0.7.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.
@@ -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
+ }