@suphark/ui 0.1.0 → 0.1.1

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.1.1] — 2026-09-18
6
+
7
+ ### Added
8
+
9
+ - `NativeSelect` / `NativeSelectOption` (shadcn `native-select` — `<select>` ธรรมดาสำหรับฟอร์ม server action/FormData) ตามที่ Pi Auth ใช้ 17 ไฟล์
10
+ - dependency `cn` (^0.3.0) — shadcn CLI รุ่นใหม่สร้างไฟล์ที่ `import { cn } from "cn"` แทน `lib/utils`; ทั้งสองทางใช้ได้ ไฟล์เดิม 59 ตัวยังใช้ `@suphark/ui/lib/utils`
11
+
12
+ ### Changed
13
+
14
+ - ตัด `__tests__`/`*.test.*` ออกจาก tarball (`files` ใน package.json) — test ยังอยู่ใน repo
15
+
16
+ ### Notes
17
+
18
+ - `ThemeToggle` props `persistedTheme` / `onThemeChange` เป็น optional อยู่แล้ว — ไม่ส่ง = ใช้ next-themes อย่างเดียว (ใช้แทน ThemeToggle ของ @pi/ui ได้โดยไม่ต้องเปลี่ยน call site)
19
+
5
20
  ## [0.1.0] — 2026-09-18
6
21
 
7
22
  รุ่นแรก — ยกจาก `supplychainnextjs-1` (source of truth) + ชุด generic จาก Pi Auth `@pi/ui`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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 เอง",
@@ -24,6 +24,8 @@
24
24
  ],
25
25
  "files": [
26
26
  "src",
27
+ "!src/**/__tests__",
28
+ "!src/**/*.test.*",
27
29
  "scripts",
28
30
  "components.json",
29
31
  "README.md",
@@ -48,6 +50,7 @@
48
50
  "class-variance-authority": "^0.7.1",
49
51
  "clsx": "^2.1.1",
50
52
  "cmdk": "^1.1.1",
53
+ "cn": "^0.3.0",
51
54
  "date-fns": "^4.4.0",
52
55
  "embla-carousel-react": "^8.6.0",
53
56
  "input-otp": "^1.5.0",
@@ -0,0 +1,54 @@
1
+ import { cn } from "cn";
2
+ import { ChevronDownIcon } from "lucide-react";
3
+ import type * as React from "react";
4
+
5
+ type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
6
+ size?: "sm" | "default";
7
+ };
8
+
9
+ function NativeSelect({ className, size = "default", ...props }: NativeSelectProps) {
10
+ return (
11
+ <div
12
+ className={cn(
13
+ "group/native-select relative w-fit has-[select:disabled]:opacity-50",
14
+ className,
15
+ )}
16
+ data-slot="native-select-wrapper"
17
+ data-size={size}
18
+ >
19
+ <select
20
+ data-slot="native-select"
21
+ data-size={size}
22
+ className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
23
+ {...props}
24
+ />
25
+ <ChevronDownIcon
26
+ className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
27
+ aria-hidden="true"
28
+ data-slot="native-select-icon"
29
+ />
30
+ </div>
31
+ );
32
+ }
33
+
34
+ function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
35
+ return (
36
+ <option
37
+ data-slot="native-select-option"
38
+ className={cn("bg-[Canvas] text-[CanvasText]", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
45
+ return (
46
+ <optgroup
47
+ data-slot="native-select-optgroup"
48
+ className={cn("bg-[Canvas] text-[CanvasText]", className)}
49
+ {...props}
50
+ />
51
+ );
52
+ }
53
+
54
+ export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
package/src/index.ts CHANGED
@@ -106,6 +106,7 @@ export * from "./components/ui/item";
106
106
  export * from "./components/ui/kbd";
107
107
  export * from "./components/ui/label";
108
108
  export * from "./components/ui/menubar";
109
+ export * from "./components/ui/native-select";
109
110
  export * from "./components/ui/navigation-menu";
110
111
  export * from "./components/ui/pagination";
111
112
  export * from "./components/ui/popover";
@@ -1,180 +0,0 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
- import { MultiSelectCombobox, type MultiSelectOption } from "../multi-select-combobox";
4
-
5
- class ResizeObserverMock {
6
- observe() {}
7
- unobserve() {}
8
- disconnect() {}
9
- }
10
-
11
- beforeEach(() => {
12
- globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
13
- window.HTMLElement.prototype.scrollIntoView = vi.fn();
14
- });
15
-
16
- const mockOptions: MultiSelectOption[] = [
17
- { label: "หมวดงานโครงสร้าง", value: "structure" },
18
- { label: "หมวดงานสถาปัตย์", value: "architecture" },
19
- { label: "หมวดงานระบบไฟฟ้า", value: "electrical" },
20
- { label: "หมวดงานสุขาภิบาล", value: "sanitary" },
21
- ];
22
-
23
- describe("MultiSelectCombobox", () => {
24
- it("renders with placeholder when no value is selected", () => {
25
- const onChange = vi.fn();
26
- render(
27
- <MultiSelectCombobox
28
- options={mockOptions}
29
- value={[]}
30
- onChange={onChange}
31
- placeholder="เลือกหมวดหมู่งาน..."
32
- />,
33
- );
34
-
35
- expect(screen.getByText("เลือกหมวดหมู่งาน...")).toBeInTheDocument();
36
- });
37
-
38
- it("renders selected items always at the top of the dropdown list", async () => {
39
- const onChange = vi.fn();
40
- // 'electrical' is selected
41
- render(
42
- <MultiSelectCombobox options={mockOptions} value={["electrical"]} onChange={onChange} />,
43
- );
44
-
45
- const trigger = screen.getByRole("combobox");
46
- fireEvent.click(trigger);
47
-
48
- // Selected item 'electrical' should be rendered first under "รายการที่เลือก"
49
- expect(screen.getByText("รายการที่เลือก")).toBeInTheDocument();
50
- expect(screen.getByText("ตัวเลือกทั้งหมด")).toBeInTheDocument();
51
-
52
- const optionItems = screen.getAllByRole("option");
53
- expect(optionItems[0]).toHaveTextContent("หมวดงานระบบไฟฟ้า");
54
- expect(optionItems[1]).toHaveTextContent("หมวดงานโครงสร้าง");
55
- expect(optionItems[2]).toHaveTextContent("หมวดงานสถาปัตย์");
56
- expect(optionItems[3]).toHaveTextContent("หมวดงานสุขาภิบาล");
57
- });
58
-
59
- it("allows selecting an unselected item and calling onChange with added value", () => {
60
- const onChange = vi.fn();
61
- render(<MultiSelectCombobox options={mockOptions} value={["structure"]} onChange={onChange} />);
62
-
63
- const trigger = screen.getByRole("combobox");
64
- fireEvent.click(trigger);
65
-
66
- // Click 'architecture'
67
- const archOption = screen.getByText("หมวดงานสถาปัตย์");
68
- fireEvent.click(archOption);
69
-
70
- expect(onChange).toHaveBeenCalledWith(["structure", "architecture"]);
71
- });
72
-
73
- it("allows unselecting an already selected item from the dropdown", () => {
74
- const onChange = vi.fn();
75
- render(
76
- <MultiSelectCombobox
77
- options={mockOptions}
78
- value={["structure", "electrical"]}
79
- onChange={onChange}
80
- />,
81
- );
82
-
83
- const trigger = screen.getByRole("combobox");
84
- fireEvent.click(trigger);
85
-
86
- // Click 'structure' option in dropdown to toggle off
87
- const optionItems = screen.getAllByRole("option");
88
- const structOption = optionItems.find((el) => el.textContent?.includes("หมวดงานโครงสร้าง"));
89
- expect(structOption).toBeDefined();
90
- fireEvent.click(structOption!);
91
-
92
- expect(onChange).toHaveBeenCalledWith(["electrical"]);
93
- });
94
-
95
- it("removes a selected item when clicking the badge remove X button", () => {
96
- const onChange = vi.fn();
97
- render(
98
- <MultiSelectCombobox
99
- options={mockOptions}
100
- value={["structure", "sanitary"]}
101
- onChange={onChange}
102
- />,
103
- );
104
-
105
- const removeBtn = screen.getByLabelText("ลบ หมวดงานโครงสร้าง");
106
- fireEvent.click(removeBtn);
107
-
108
- expect(onChange).toHaveBeenCalledWith(["sanitary"]);
109
- });
110
-
111
- it("clears all selected items when clicking the clear all button", () => {
112
- const onChange = vi.fn();
113
- render(
114
- <MultiSelectCombobox
115
- options={mockOptions}
116
- value={["structure", "sanitary"]}
117
- onChange={onChange}
118
- />,
119
- );
120
-
121
- const clearAllBtn = screen.getByLabelText("ล้างทั้งหมด");
122
- fireEvent.click(clearAllBtn);
123
-
124
- expect(onChange).toHaveBeenCalledWith([]);
125
- });
126
-
127
- it("limits badges display with maxDisplay and shows overflow badge count", () => {
128
- const onChange = vi.fn();
129
- render(
130
- <MultiSelectCombobox
131
- options={mockOptions}
132
- value={["structure", "architecture", "electrical", "sanitary"]}
133
- onChange={onChange}
134
- maxDisplay={2}
135
- />,
136
- );
137
-
138
- expect(screen.getByText("หมวดงานโครงสร้าง")).toBeInTheDocument();
139
- expect(screen.getByText("หมวดงานสถาปัตย์")).toBeInTheDocument();
140
- expect(screen.queryByText("หมวดงานระบบไฟฟ้า")).not.toBeInTheDocument();
141
- expect(screen.getByText("+2 รายการ")).toBeInTheDocument();
142
- });
143
-
144
- it("filters options via search input preserving selected items on top", async () => {
145
- const onChange = vi.fn();
146
- render(
147
- <MultiSelectCombobox options={mockOptions} value={["electrical"]} onChange={onChange} />,
148
- );
149
-
150
- const trigger = screen.getByRole("combobox");
151
- fireEvent.click(trigger);
152
-
153
- const searchInput = screen.getByPlaceholderText("ค้นหา...");
154
- fireEvent.change(searchInput, { target: { value: "งาน" } });
155
-
156
- // All matching options still show, with selected 'electrical' on top
157
- const options = screen.getAllByRole("option");
158
- expect(options[0]).toHaveTextContent("หมวดงานระบบไฟฟ้า");
159
- });
160
-
161
- it("supports select all action when showSelectAll is true", () => {
162
- const onChange = vi.fn();
163
- render(
164
- <MultiSelectCombobox
165
- options={mockOptions}
166
- value={["structure"]}
167
- onChange={onChange}
168
- showSelectAll={true}
169
- />,
170
- );
171
-
172
- const trigger = screen.getByRole("combobox");
173
- fireEvent.click(trigger);
174
-
175
- const selectAllBtn = screen.getByText("เลือกทั้งหมด");
176
- fireEvent.click(selectAllBtn);
177
-
178
- expect(onChange).toHaveBeenCalledWith(["structure", "architecture", "electrical", "sanitary"]);
179
- });
180
- });
@@ -1,24 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { toIdentifier } from "../name-id-fields";
3
-
4
- describe("toIdentifier", () => {
5
- it("kebab: slug จากชื่อองค์กร", () => {
6
- expect(toIdentifier("Acme PCL", "kebab")).toBe("acme-pcl");
7
- expect(toIdentifier(" Acme Co., Ltd. ", "kebab")).toBe("acme-co-ltd");
8
- expect(toIdentifier("Café Résumé", "kebab")).toBe("cafe-resume");
9
- });
10
- it("camel: key ของ attribute", () => {
11
- expect(toIdentifier("Cost Center", "camel")).toBe("costCenter");
12
- expect(toIdentifier("2FA Enabled", "camel")).toBe("x2faEnabled");
13
- });
14
- it("upper: รหัสตำแหน่ง", () => {
15
- expect(toIdentifier("Senior Director", "upper")).toBe("SENIOR_DIRECTOR");
16
- });
17
- it("ชื่อไทยล้วน → ว่าง (ให้ผู้ใช้ตั้งเอง); ผสม → เอาเฉพาะละติน", () => {
18
- expect(toIdentifier("ฝ่ายวิศวกรรม", "kebab")).toBe("");
19
- expect(toIdentifier("ฝ่าย IT Support", "kebab")).toBe("it-support");
20
- });
21
- it("ตัดความยาวแล้วไม่ทิ้งขีดท้าย", () => {
22
- expect(toIdentifier("alpha beta gamma", "kebab", 11)).toBe("alpha-beta");
23
- });
24
- });
@@ -1,72 +0,0 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { SimpleSelect, type SimpleSelectGroup, type SimpleSelectOption } from "../simple-select";
4
-
5
- describe("SimpleSelect Component", () => {
6
- it("renders flat options and displays label instead of raw ID", () => {
7
- const options: SimpleSelectOption[] = [
8
- { value: "none", label: "ไม่ระบุ" },
9
- { value: "550e8400-e29b-41d4-a716-446655440000", label: "โครงการ A" },
10
- { value: "660e8400-e29b-41d4-a716-446655440001", label: "โครงการ B" },
11
- ];
12
-
13
- render(
14
- <SimpleSelect
15
- value="550e8400-e29b-41d4-a716-446655440000"
16
- options={options}
17
- onValueChange={vi.fn()}
18
- />,
19
- );
20
-
21
- // Should display the label "โครงการ A"
22
- expect(screen.getByText("โครงการ A")).toBeInTheDocument();
23
- // Should NOT display the raw UUID
24
- expect(screen.queryByText("550e8400-e29b-41d4-a716-446655440000")).not.toBeInTheDocument();
25
- });
26
-
27
- it("renders grouped options and displays label for selected item", () => {
28
- const groups: SimpleSelectGroup[] = [
29
- {
30
- label: "กลุ่มที่ 1",
31
- options: [
32
- { value: "step-1::field-1", label: "เลขที่เอกสาร (Text)" },
33
- { value: "step-1::field-2", label: "คู่ค้า (Select)" },
34
- ],
35
- },
36
- {
37
- label: "กลุ่มที่ 2",
38
- options: [{ value: "step-2::field-3", label: "ยอดเงิน (Number)" }],
39
- },
40
- ];
41
-
42
- render(<SimpleSelect value="step-1::field-2" options={groups} onValueChange={vi.fn()} />);
43
-
44
- expect(screen.getByText("คู่ค้า (Select)")).toBeInTheDocument();
45
- expect(screen.queryByText("step-1::field-2")).not.toBeInTheDocument();
46
- });
47
-
48
- it("uses fallbackLabel when the selected value is not in options", () => {
49
- const options: SimpleSelectOption[] = [{ value: "opt-1", label: "ตัวเลือก 1" }];
50
-
51
- render(
52
- <SimpleSelect
53
- value="orphaned-step-id::field-abc"
54
- options={options}
55
- fallbackLabel={(val) => `ค้นพบจากประวัติ: ${val.split("::").pop()}`}
56
- onValueChange={vi.fn()}
57
- />,
58
- );
59
-
60
- expect(screen.getByText("ค้นพบจากประวัติ: field-abc")).toBeInTheDocument();
61
- });
62
-
63
- it("renders placeholder when value is empty or unset", () => {
64
- const options: SimpleSelectOption[] = [{ value: "opt-1", label: "ตัวเลือก 1" }];
65
-
66
- render(
67
- <SimpleSelect value="" options={options} placeholder="กรุณาเลือก..." onValueChange={vi.fn()} />,
68
- );
69
-
70
- expect(screen.getByText("กรุณาเลือก...")).toBeInTheDocument();
71
- });
72
- });
@@ -1,40 +0,0 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxList } from "../combobox";
4
-
5
- describe("ComboboxContent", () => {
6
- it("renders selectable items inside a supplied modal container", () => {
7
- const onValueChange = vi.fn();
8
-
9
- render(<ModalCombobox onValueChange={onValueChange} />);
10
-
11
- const option = screen.getByText("ครัว");
12
-
13
- fireEvent.click(option);
14
-
15
- expect(onValueChange).toHaveBeenCalledWith("ครัว", expect.anything());
16
- });
17
- });
18
-
19
- function ModalCombobox({
20
- onValueChange,
21
- }: {
22
- onValueChange: (value: string | null, eventDetails: unknown) => void;
23
- }) {
24
- return (
25
- <div data-testid="modal-container">
26
- <Combobox items={["ครัว"]} open onValueChange={onValueChange}>
27
- <ComboboxInput />
28
- <ComboboxContent>
29
- <ComboboxList>
30
- {(item) => (
31
- <ComboboxItem key={item} value={item}>
32
- {item}
33
- </ComboboxItem>
34
- )}
35
- </ComboboxList>
36
- </ComboboxContent>
37
- </Combobox>
38
- </div>
39
- );
40
- }
@@ -1,53 +0,0 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
- import { describe, expect, it, vi } from "vitest";
3
- import { DatePicker } from "../date-picker";
4
-
5
- function renderInsideForm(onChange = vi.fn(), onSubmit = vi.fn()) {
6
- const view = render(
7
- <form
8
- onSubmit={(event) => {
9
- event.preventDefault();
10
- onSubmit();
11
- }}
12
- >
13
- <DatePicker
14
- value={new Date(2025, 9, 10)}
15
- onChange={onChange}
16
- showPresets={false}
17
- allowClear={false}
18
- />
19
- <button type="submit">Save parent form</button>
20
- </form>,
21
- );
22
-
23
- fireEvent.click(screen.getByRole("button", { name: /10 ต.ค. 2568/ }));
24
- const smartInput = screen.getByPlaceholderText("เช่น 1/7, 1/7/25, 1-7-2568...");
25
- fireEvent.change(smartInput, { target: { value: "16/10/2025" } });
26
-
27
- return { ...view, smartInput };
28
- }
29
-
30
- describe("DatePicker smart input", () => {
31
- it("confirms a typed date without submitting its parent form when clicking ตกลง", () => {
32
- const onChange = vi.fn();
33
- const onSubmit = vi.fn();
34
- const { container } = renderInsideForm(onChange, onSubmit);
35
-
36
- fireEvent.click(screen.getByRole("button", { name: "ตกลง" }));
37
-
38
- expect(container.querySelectorAll("form")).toHaveLength(1);
39
- expect(onSubmit).not.toHaveBeenCalled();
40
- expect(onChange).toHaveBeenCalledWith(new Date(2025, 9, 16));
41
- });
42
-
43
- it("confirms a typed date without submitting its parent form when pressing Enter", () => {
44
- const onChange = vi.fn();
45
- const onSubmit = vi.fn();
46
- const { smartInput } = renderInsideForm(onChange, onSubmit);
47
-
48
- fireEvent.keyDown(smartInput, { key: "Enter", code: "Enter" });
49
-
50
- expect(onSubmit).not.toHaveBeenCalled();
51
- expect(onChange).toHaveBeenCalledWith(new Date(2025, 9, 16));
52
- });
53
- });
@@ -1,18 +0,0 @@
1
- import { render, screen, waitFor } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
3
- import { Slider } from "../slider";
4
-
5
- describe("Slider", () => {
6
- it("renders the standard accessible slider contract", async () => {
7
- const { container } = render(<Slider aria-label="ระดับ" defaultValue={[25]} />);
8
-
9
- expect(screen.getByRole("group", { name: "ระดับ" })).toBeInTheDocument();
10
- expect(container.querySelector('[data-slot="slider"]')).toHaveAttribute(
11
- "data-orientation",
12
- "horizontal",
13
- );
14
- await waitFor(() => {
15
- expect(container.querySelector('input[type="range"]')).toHaveAttribute("aria-valuenow", "25");
16
- });
17
- });
18
- });
@@ -1,33 +0,0 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
3
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs";
4
-
5
- describe("Tabs", () => {
6
- it("uses the orientation data attributes supplied by Base UI", () => {
7
- const { container } = render(<ExampleTabs />);
8
-
9
- expect(container.firstElementChild).toHaveAttribute("data-horizontal");
10
- expect(container.firstElementChild).toHaveAttribute("data-orientation", "horizontal");
11
- });
12
-
13
- it("changes the active panel when a trigger is selected", () => {
14
- render(<ExampleTabs />);
15
-
16
- fireEvent.click(screen.getByRole("tab", { name: "รายละเอียด" }));
17
-
18
- expect(screen.getByRole("tabpanel")).toHaveTextContent("เนื้อหารายละเอียด");
19
- });
20
- });
21
-
22
- function ExampleTabs() {
23
- return (
24
- <Tabs defaultValue="overview">
25
- <TabsList>
26
- <TabsTrigger value="overview">ภาพรวม</TabsTrigger>
27
- <TabsTrigger value="details">รายละเอียด</TabsTrigger>
28
- </TabsList>
29
- <TabsContent value="overview">เนื้อหาภาพรวม</TabsContent>
30
- <TabsContent value="details">เนื้อหารายละเอียด</TabsContent>
31
- </Tabs>
32
- );
33
- }