@suphark/ui 0.8.0 → 0.10.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 +19 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/components/shared/navigation/section-nav-bar.tsx +17 -0
- package/src/components/shared/navigation/section-nav.tsx +79 -0
- package/src/components/shared/rule-builder.tsx +465 -0
- package/src/components/shared/ui-text.tsx +4 -0
- package/src/index.ts +14 -0
- package/src/next.ts +5 -0
- package/src/styles.css +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.10.0] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`SectionNav`** (`@suphark/ui/next`, สเปกจาก Pi Auth — ชิ้นสุดท้ายที่แอปยังประกอบเอง) — หัวหมวด + แถบแท็บย่อยใต้หัวข้อสำหรับ layout ของหมวดที่มีหลายหน้า (directory/access/security/integrations): `title`/`description`/`actions` ส่งต่อ `PageHeader`, `tabs[{ href? | slug?, label, exact?, badge? }]` (href หรือ `${base}/${slug}`), แต่ละแท็บเป็น `NavLink` (`-mb-px border-b-2` ไฮไลต์ `border-primary` ตาม pathname, `aria-current=page`), badge ต่อท้าย label, `navLabel` (ค่าเริ่มต้น `UiText.sectionNav.tabs` — "แท็บย่อย"/"Section tabs"), ไม่มี tabs = ไม่มี nav; ไม่มี state ใช้จาก server component ได้ (เฉพาะแถบ `<nav>` เป็น client component เล็ก ๆ เพื่ออ่าน UiText); types `SectionNavProps`, `SectionNavTab`; `UiText.sectionNav` (optional)
|
|
10
|
+
- showcase: หน้า `section-nav` — 97 หน้า; tests 3 กรณี (รวม 126)
|
|
11
|
+
|
|
12
|
+
## [0.9.0] — 2026-09-21
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`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`
|
|
17
|
+
- 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")`
|
|
18
|
+
- showcase: หน้า `rule-builder` (ชุดกฎ storage ตัวอย่าง + JSON สด) — 96 หน้า; tests 4 กรณี (รวม 123)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`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 ได้ผลด้วย)
|
|
23
|
+
|
|
5
24
|
## [0.8.0] — 2026-09-21
|
|
6
25
|
|
|
7
26
|
รอบ 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.10.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,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/** แถบแท็บของ SectionNav — แยกเป็น client เพื่ออ่าน aria-label ค่าเริ่มต้นจาก UiText (context) */
|
|
7
|
+
export function SectionNavBar({ label, children }: { label?: string; children: ReactNode }) {
|
|
8
|
+
const text = useUiText();
|
|
9
|
+
return (
|
|
10
|
+
<nav
|
|
11
|
+
aria-label={label ?? text.sectionNav?.tabs ?? "แท็บย่อย"}
|
|
12
|
+
className="flex flex-wrap gap-1 border-b text-sm"
|
|
13
|
+
>
|
|
14
|
+
{children}
|
|
15
|
+
</nav>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { NavLink } from "@suphark/ui/components/app-shell/nav-link";
|
|
2
|
+
import { PageHeader } from "@suphark/ui/components/shared/page-header";
|
|
3
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { SectionNavBar } from "./section-nav-bar";
|
|
6
|
+
|
|
7
|
+
export interface SectionNavTab {
|
|
8
|
+
/** ลิงก์เต็ม — หรือส่ง `slug` แล้วให้ `base` ของ SectionNav ประกอบเป็น `${base}/${slug}` */
|
|
9
|
+
href?: string;
|
|
10
|
+
slug?: string;
|
|
11
|
+
label: ReactNode;
|
|
12
|
+
/** active เฉพาะ path ตรงเป๊ะ (ค่าเริ่มต้น prefix match) */
|
|
13
|
+
exact?: boolean;
|
|
14
|
+
/** ต่อท้าย label เช่น จำนวน */
|
|
15
|
+
badge?: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SectionNavProps {
|
|
19
|
+
title: ReactNode;
|
|
20
|
+
description?: ReactNode;
|
|
21
|
+
actions?: ReactNode;
|
|
22
|
+
/** prefix ของ href เมื่อแท็บให้ `slug` (เช่น "/directory") */
|
|
23
|
+
base?: string;
|
|
24
|
+
tabs?: ReadonlyArray<SectionNavTab>;
|
|
25
|
+
/** aria-label ของแถบแท็บ (ค่าเริ่มต้น `UiText.sectionNav.tabs` — "แท็บย่อย") */
|
|
26
|
+
navLabel?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tabHref(tab: SectionNavTab, base?: string): string {
|
|
31
|
+
if (tab.href) return tab.href;
|
|
32
|
+
const b = (base ?? "").replace(/\/+$/, "");
|
|
33
|
+
return `${b}/${(tab.slug ?? "").replace(/^\/+/, "")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* หัวหมวด + แถบแท็บย่อยใต้หัวข้อ สำหรับ layout ของหมวดที่มีหลายหน้า (directory/access/security/integrations …)
|
|
38
|
+
* = `PageHeader` + `NavLink` แต่ละแท็บ (ไฮไลต์ตาม pathname) — ไม่มี state ใช้จาก server component ได้
|
|
39
|
+
*
|
|
40
|
+
* <SectionNav title="ไดเรกทอรี" base="/directory" tabs={[{ slug: "users", label: "ผู้ใช้" }, { slug: "groups", label: "กลุ่ม", badge: 3 }]} />
|
|
41
|
+
*/
|
|
42
|
+
export function SectionNav({
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
actions,
|
|
46
|
+
base,
|
|
47
|
+
tabs,
|
|
48
|
+
navLabel,
|
|
49
|
+
className,
|
|
50
|
+
}: SectionNavProps) {
|
|
51
|
+
return (
|
|
52
|
+
<div data-slot="section-nav" className={cn("flex flex-col gap-3", className)}>
|
|
53
|
+
<PageHeader title={title} description={description} actions={actions} />
|
|
54
|
+
{tabs && tabs.length > 0 && (
|
|
55
|
+
<SectionNavBar label={navLabel}>
|
|
56
|
+
{tabs.map((tab) => {
|
|
57
|
+
const href = tabHref(tab, base);
|
|
58
|
+
return (
|
|
59
|
+
<NavLink
|
|
60
|
+
key={href}
|
|
61
|
+
href={href}
|
|
62
|
+
exact={tab.exact}
|
|
63
|
+
className="-mb-px inline-flex items-center gap-1.5 border-transparent border-b-2 px-3 py-2 text-muted-foreground transition-colors hover:text-foreground"
|
|
64
|
+
activeClassName="border-primary font-medium text-foreground"
|
|
65
|
+
>
|
|
66
|
+
{tab.label}
|
|
67
|
+
{tab.badge != null && (
|
|
68
|
+
<span className="rounded-full bg-muted px-1.5 text-muted-foreground text-xs tabular-nums">
|
|
69
|
+
{tab.badge}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
</NavLink>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</SectionNavBar>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -47,6 +47,8 @@ export interface UiText {
|
|
|
47
47
|
signOut?: string;
|
|
48
48
|
/** ThemeMenuItems: หัวกลุ่ม + ตัวเลือก (optional) */
|
|
49
49
|
theme?: Partial<{ label: string; light: string; dark: string; system: string }>;
|
|
50
|
+
/** SectionNav: aria-label ของแถบแท็บย่อย (optional — "แท็บย่อย") */
|
|
51
|
+
sectionNav?: Partial<{ tabs: string }>;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -79,6 +81,7 @@ export const UI_TEXT_TH: UiText = {
|
|
|
79
81
|
userMenu: "เมนูผู้ใช้",
|
|
80
82
|
signOut: "ออกจากระบบ",
|
|
81
83
|
theme: { label: "ธีม", light: "สว่าง", dark: "มืด", system: "ตามระบบ" },
|
|
84
|
+
sectionNav: { tabs: "แท็บย่อย" },
|
|
82
85
|
};
|
|
83
86
|
|
|
84
87
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -111,6 +114,7 @@ export const UI_TEXT_EN: UiText = {
|
|
|
111
114
|
userMenu: "User menu",
|
|
112
115
|
signOut: "Sign out",
|
|
113
116
|
theme: { label: "Theme", light: "Light", dark: "Dark", system: "System" },
|
|
117
|
+
sectionNav: { tabs: "Section tabs" },
|
|
114
118
|
};
|
|
115
119
|
|
|
116
120
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
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/next.ts
CHANGED
|
@@ -15,6 +15,11 @@ export {
|
|
|
15
15
|
PageBreadcrumb,
|
|
16
16
|
type PageBreadcrumbItem,
|
|
17
17
|
} from "./components/shared/navigation/page-breadcrumb";
|
|
18
|
+
export {
|
|
19
|
+
SectionNav,
|
|
20
|
+
type SectionNavProps,
|
|
21
|
+
type SectionNavTab,
|
|
22
|
+
} from "./components/shared/navigation/section-nav";
|
|
18
23
|
export { Stepper, type StepperStep, type StepStatus } from "./components/shared/navigation/stepper";
|
|
19
24
|
export { type RowAction, type RowActionItem, RowActions } from "./components/shared/row-actions";
|
|
20
25
|
export { RowMenu, type RowMenuDialog, type RowMenuItem } from "./components/shared/row-menu";
|
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
|
*,
|