@suphark/ui 0.26.0 → 0.27.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +31 -3
  3. package/package.json +5 -3
  4. package/src/components/app-shell/command-palette.tsx +14 -6
  5. package/src/components/data-table/data-table-pagination.tsx +7 -5
  6. package/src/components/data-table/data-table-sortable.tsx +5 -4
  7. package/src/components/data-table/data-table-toolbar.tsx +15 -4
  8. package/src/components/data-table/use-data-table.ts +38 -5
  9. package/src/components/design-system/design-system-showcase.tsx +1 -0
  10. package/src/components/pwa/pull-to-refresh.tsx +11 -4
  11. package/src/components/pwa/service-worker-updates.ts +26 -3
  12. package/src/components/shared/action-menu.tsx +26 -11
  13. package/src/components/shared/auth-parts.tsx +5 -3
  14. package/src/components/shared/button/button.tsx +110 -0
  15. package/src/components/shared/button/confirm-action-button.tsx +9 -13
  16. package/src/components/shared/button/index.ts +6 -0
  17. package/src/components/shared/chat-thread.tsx +13 -6
  18. package/src/components/shared/client-redirect.tsx +5 -9
  19. package/src/components/shared/dialogs-provider.tsx +31 -0
  20. package/src/components/shared/form/number-input.tsx +28 -13
  21. package/src/components/shared/form/tags-input.tsx +26 -21
  22. package/src/components/shared/form/time-picker.tsx +2 -2
  23. package/src/components/shared/month-stepper.tsx +4 -4
  24. package/src/components/shared/notice.tsx +1 -1
  25. package/src/components/shared/resource-property-filter.tsx +1 -1
  26. package/src/components/shared/row-actions.tsx +14 -8
  27. package/src/components/shared/rule-builder.tsx +11 -2
  28. package/src/components/shared/secret-reveal.tsx +1 -1
  29. package/src/components/shared/settings-layout.tsx +8 -3
  30. package/src/components/shared/simple-dialog.tsx +1 -1
  31. package/src/components/shared/simple-pagination.tsx +1 -1
  32. package/src/components/shared/simple-tooltip.tsx +11 -6
  33. package/src/components/shared/step-form.tsx +33 -13
  34. package/src/components/shared/theme/theme-switch.tsx +1 -1
  35. package/src/components/shared/theme/theme-toggle.tsx +5 -3
  36. package/src/components/shared/ui-text.tsx +25 -4
  37. package/src/components/shared/upload-list.tsx +1 -1
  38. package/src/hooks/use-table-search-params.ts +9 -4
  39. package/src/index.ts +7 -1
  40. package/src/lib/action-result.ts +33 -0
  41. package/src/lib/safe-url.ts +15 -0
  42. package/src/styles.css +21 -18
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  Dialog,
6
6
  DialogClose,
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { cn } from "@suphark/ui/lib/utils";
5
5
  import { ChevronLeft, ChevronRight } from "lucide-react";
6
6
  import { useUiText } from "./ui-text";
@@ -11,12 +11,14 @@ import {
11
11
  import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
12
12
  import { cn } from "@suphark/ui/lib/utils";
13
13
  import type { ReactElement, ReactNode } from "react";
14
+ import { ButtonTooltipSuppress } from "./button/button";
14
15
 
15
16
  type Side = "top" | "bottom" | "left" | "right";
16
17
 
17
18
  /**
18
19
  * tooltip สำเร็จรูป: ห่อ element เดียว + ข้อความ (แทน Tooltip/TooltipTrigger/TooltipContent 3 ชิ้น)
19
20
  * `content` ว่าง = ไม่มี tooltip (ไม่ต้องเขียนเงื่อนไขเอง) · ปุ่มไอคอนอย่างเดียวยังต้องมี aria-label ของตัวเอง
21
+ * ปุ่มไอคอน (`Button` + `aria-label`) **ไม่ต้องห่อตัวนี้แล้ว** ตั้งแต่ 0.27.0 — ห่อได้ถ้าอยากใช้ข้อความอื่น
20
22
  * คำอธิบายยาวหลังไอคอน (i) ใช้ `InfoHint`
21
23
  *
22
24
  * <SimpleTooltip content="คัดลอกลิงก์"><Button size="icon" aria-label="คัดลอกลิงก์"><Copy /></Button></SimpleTooltip>
@@ -34,13 +36,16 @@ export function SimpleTooltip({
34
36
  className?: string;
35
37
  }) {
36
38
  if (content === null || content === undefined || content === "") return children;
39
+ // ลูกที่เป็น `Button` ของแพ็กเกจจะไม่สร้าง tooltip อัตโนมัติซ้อนขึ้นมาอีกชั้น
37
40
  return (
38
- <Tooltip>
39
- <TooltipTrigger render={children} />
40
- <TooltipContent side={side} className={cn("max-w-xs text-pretty", className)}>
41
- {content}
42
- </TooltipContent>
43
- </Tooltip>
41
+ <ButtonTooltipSuppress>
42
+ <Tooltip>
43
+ <TooltipTrigger render={children} />
44
+ <TooltipContent side={side} className={cn("max-w-xs text-pretty", className)}>
45
+ {content}
46
+ </TooltipContent>
47
+ </Tooltip>
48
+ </ButtonTooltipSuppress>
44
49
  );
45
50
  }
46
51
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
4
  import { Spinner } from "@suphark/ui/components/ui/spinner";
5
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
5
6
  import { cn } from "@suphark/ui/lib/utils";
6
7
  import { CheckIcon } from "lucide-react";
7
8
  import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
@@ -19,6 +20,21 @@ export interface StepFormStep {
19
20
 
20
21
  const CONTROLS = "input, select, textarea";
21
22
 
23
+ const FOCUSABLE = 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])';
24
+
25
+ /** สิ่งที่โฟกัสได้ (มองเห็น ไม่ใช่ตัวแทนที่ซ่อน) ตัวก่อนหน้าที่ใกล้ `el` ที่สุดในกล่อง */
26
+ function precedingFocusable(section: HTMLElement, el: Element): HTMLElement | undefined {
27
+ const all = [...section.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
28
+ (c) =>
29
+ c !== el &&
30
+ c.getAttribute("aria-hidden") !== "true" &&
31
+ (c as HTMLInputElement).type !== "hidden" &&
32
+ !(c as HTMLButtonElement).disabled &&
33
+ c.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING,
34
+ );
35
+ return all[all.length - 1];
36
+ }
37
+
22
38
  /**
23
39
  * ตรวจ constraint ของ input ในกล่อง (required, pattern, min…) — แสดง bubble ของเบราว์เซอร์ที่ช่องแรกที่ผิด
24
40
  * input ที่ซ่อนไว้ (ตัวแทนของ Base UI เช่น NumberField, aria-hidden) แสดง bubble ไม่ได้ → คืนข้อความให้แสดงใต้ฟอร์มแทน
@@ -27,16 +43,13 @@ const CONTROLS = "input, select, textarea";
27
43
  function reportSection(section: HTMLElement | null): true | false | string {
28
44
  if (!section) return true;
29
45
  const controls = [...section.querySelectorAll<HTMLInputElement>(CONTROLS)];
30
- for (const [i, el] of controls.entries()) {
46
+ for (const el of controls) {
31
47
  if (el.type === "hidden" || el.disabled) continue;
32
48
  if (!el.checkValidity()) {
33
49
  if (el.getAttribute("aria-hidden") === "true") {
34
- // input ซ่อนของ Base UI อยู่ถัดจากช่องที่มองเห็นของมัน → โฟกัสช่องที่มองเห็นตัวก่อนหน้าที่ใกล้ที่สุด
35
- controls
36
- .slice(0, i)
37
- .reverse()
38
- .find((c) => c.getAttribute("aria-hidden") !== "true" && c.type !== "hidden")
39
- ?.focus();
50
+ // input ซ่อนของ Base UI (NumberField/Select/Checkbox/Switch/Radio) อยู่ถัดจากตัวควบคุมที่มองเห็นของมัน
51
+ // → โฟกัสสิ่งที่โฟกัสได้ตัวก่อนหน้าที่ใกล้ที่สุด (ปุ่ม combobox/checkbox ก็นับ ไม่ใช่แค่ input)
52
+ precedingFocusable(section, el)?.focus();
40
53
  return el.validationMessage || "ค่าไม่ถูกต้อง";
41
54
  }
42
55
  el.reportValidity();
@@ -92,6 +105,8 @@ export function StepForm({
92
105
  const busy = useRef(false);
93
106
  /** เวลาที่เปลี่ยนขั้นล่าสุด — คลิกที่สองของดับเบิลคลิก "ถัดไป" ตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม */
94
107
  const movedAt = useRef(0);
108
+ /** เวลาที่ส่งเสร็จล่าสุด — กันคลิกซ้ำเมื่อ onSubmit จบเร็ว */
109
+ const submittedAt = useRef(0);
95
110
  const last = steps.length - 1;
96
111
  const step = steps[current];
97
112
 
@@ -132,8 +147,10 @@ export function StepForm({
132
147
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
133
148
  e.preventDefault();
134
149
  if (busy.current) return;
135
- // ปุ่ม "ถัดไป" กลายเป็น "ส่ง" ที่ตำแหน่งเดิม — คลิกที่มาเร็วเกินหลังเปลี่ยนขั้นไม่นับเป็นการส่ง (ผู้ใช้ยังไม่ได้เห็นหน้าตรวจทาน)
136
- if (current === last && Date.now() - movedAt.current < 500) return;
150
+ // คลิกที่สองของดับเบิลคลิก: หลังเปลี่ยนขั้น (ปุ่มเดิมกลายเป็น "ถัดไป"/"ส่ง" ของขั้นใหม่ — ข้ามขั้นหรือข้ามหน้าตรวจทาน)
151
+ // และหลังส่งเสร็จ (onSubmit ที่ sync/เร็ว ปล่อย busy ก่อนคลิกที่สองมาถึง) ไม่นับ
152
+ const now = Date.now();
153
+ if (now - movedAt.current < 500 || now - submittedAt.current < 500) return;
137
154
  busy.current = true;
138
155
  try {
139
156
  await submitStep();
@@ -174,13 +191,16 @@ export function StepForm({
174
191
  }
175
192
  if (!formRef.current) return;
176
193
  setPending(true);
194
+ const fallback = ui.actionFailed ?? "ดำเนินการไม่สำเร็จ";
177
195
  try {
178
- await onSubmit(new FormData(formRef.current));
196
+ // server action ที่คืน { ok: false, error } (FormState) = ล้มเหลว เหมือน throw
197
+ const failed = failedResult(await onSubmit(new FormData(formRef.current)), fallback);
198
+ if (failed) setError(failed);
179
199
  } catch (err) {
180
- setError(
181
- err instanceof Error && err.message ? err.message : (ui.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
182
- );
200
+ // redirect() ใน server action = สำเร็จ (Next นำทางให้แล้ว)
201
+ if (!isNavigationError(err)) setError(errorMessage(err, fallback));
183
202
  } finally {
203
+ submittedAt.current = Date.now();
184
204
  setPending(false);
185
205
  }
186
206
  };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { MoonIcon, SunIcon } from "lucide-react";
5
5
  import { useTheme } from "next-themes";
6
6
  import { useEffect, useState } from "react";
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
5
  import {
5
6
  DropdownMenu,
6
7
  DropdownMenuContent,
@@ -30,6 +31,8 @@ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps)
30
31
  lastPersisted.current = persistedTheme;
31
32
  }, [persistedTheme]);
32
33
 
34
+ const text = useUiText();
35
+
33
36
  const handleThemeChange = async (newTheme: ThemeValue) => {
34
37
  setTheme(newTheme);
35
38
  await onThemeChange?.(newTheme);
@@ -43,13 +46,12 @@ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps)
43
46
  variant="ghost"
44
47
  size="icon"
45
48
  className="relative h-9 w-9 rounded-md hover:bg-accent hover:text-accent-foreground"
46
- title="Change theme"
49
+ aria-label={text.theme?.label ?? "Change theme"}
47
50
  />
48
51
  }
49
52
  >
50
53
  <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
51
54
  <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
52
- <span className="sr-only">Change theme</span>
53
55
  </DropdownMenuTrigger>
54
56
  <DropdownMenuContent align="end" className="w-36">
55
57
  <DropdownMenuItem
@@ -81,8 +81,15 @@ export interface UiText {
81
81
  }>;
82
82
  /** ChatThread (optional) */
83
83
  chat?: Partial<{ placeholder: string; send: string; typing: string; scrollToLatest: string }>;
84
- /** SimplePagination (optional) */
85
- pagination?: Partial<{ previous: string; next: string; label: string; page: string }>;
84
+ /** SimplePagination / DataTablePagination (optional) */
85
+ pagination?: Partial<{
86
+ previous: string;
87
+ next: string;
88
+ first: string;
89
+ last: string;
90
+ label: string;
91
+ page: string;
92
+ }>;
86
93
  /** TableSkeleton/ListSkeleton/CardSkeleton (optional) — ข้อความสำหรับ screen reader */
87
94
  loading?: string;
88
95
  /** สถานะออนไลน์ของ UserIdentity/Initials (optional) */
@@ -174,7 +181,14 @@ export const UI_TEXT_TH: UiText = {
174
181
  typing: "กำลังพิมพ์…",
175
182
  scrollToLatest: "ไปข้อความล่าสุด",
176
183
  },
177
- pagination: { previous: "ก่อนหน้า", next: "ถัดไป", label: "แบ่งหน้า", page: "หน้า" },
184
+ pagination: {
185
+ previous: "ก่อนหน้า",
186
+ next: "ถัดไป",
187
+ first: "หน้าแรก",
188
+ last: "หน้าสุดท้าย",
189
+ label: "แบ่งหน้า",
190
+ page: "หน้า",
191
+ },
178
192
  loading: "กำลังโหลด…",
179
193
  presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
180
194
  characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
@@ -264,7 +278,14 @@ export const UI_TEXT_EN: UiText = {
264
278
  typing: "Typing…",
265
279
  scrollToLatest: "Jump to latest",
266
280
  },
267
- pagination: { previous: "Previous", next: "Next", label: "Pagination", page: "Page" },
281
+ pagination: {
282
+ previous: "Previous",
283
+ next: "Next",
284
+ first: "First page",
285
+ last: "Last page",
286
+ label: "Pagination",
287
+ page: "Page",
288
+ },
268
289
  loading: "Loading…",
269
290
  presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
270
291
  characterCount: "{count} of {max} characters",
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { formatBytes } from "@suphark/ui/lib/formatters";
5
5
  import { cn } from "@suphark/ui/lib/utils";
6
6
  import { AlertCircle, Check, ChevronDown, RotateCcw, X } from "lucide-react";
@@ -45,7 +45,8 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
45
45
  const [isPending, startTransition] = useTransition();
46
46
 
47
47
  const state = useMemo<TableSearchState>(() => {
48
- const filters: Record<string, string[]> = {};
48
+ // ไม่มี prototype: key จาก URL อย่าง "__proto__"/"constructor" เป็นแค่ตัวกรองชื่อหนึ่ง ไม่ไปทับ prototype
49
+ const filters: Record<string, string[]> = Object.create(null);
49
50
  for (const key of new Set(searchParams.keys())) {
50
51
  if (RESERVED.has(key)) continue;
51
52
  const values = searchParams
@@ -73,16 +74,20 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
73
74
 
74
75
  // setter หลายตัวในจังหวะเดียว (เช่น setFilter แล้ว setSort) ต้องต่อจากค่าที่เพิ่งตั้ง ไม่ใช่ searchParams ตอน render
75
76
  // (router.replace ยังไม่ทันเปลี่ยน URL → ตัวหลังเขียนทับตัวแรก) — ล้างเมื่อ URL เปลี่ยนจริงแล้ว
76
- const latest = useRef<string | null>(null);
77
+ // จำ URL ที่เราเพิ่งสั่ง (value) คู่กับ URL ตอนสั่ง (base) — ใช้ต่อเฉพาะเมื่อ URL ยังเป็น base เดิม
78
+ // (เดิมล้างตามตัวอ้างอิงของ searchParams ซึ่ง Next คืนตัวเดิมเมื่อ URL ไม่เปลี่ยน → ค่าเก่าค้าง)
79
+ const latest = useRef<{ base: string; value: string } | null>(null);
77
80
  useEffect(() => {
78
81
  latest.current = null;
79
82
  }, [searchParams]);
80
83
 
81
84
  const push = useCallback(
82
85
  (mutate: (params: URLSearchParams) => void) => {
83
- const params = new URLSearchParams(latest.current ?? searchParams.toString());
86
+ const base = searchParams.toString();
87
+ const pending = latest.current?.base === base ? latest.current.value : base;
88
+ const params = new URLSearchParams(pending);
84
89
  mutate(params);
85
- latest.current = params.toString();
90
+ latest.current = { base, value: params.toString() };
86
91
  startTransition(() => {
87
92
  router.replace(`${pathname}?${params.toString()}`, { scroll: false });
88
93
  });
package/src/index.ts CHANGED
@@ -40,6 +40,12 @@ export {
40
40
  SaveButton,
41
41
  SubmitButton,
42
42
  } from "./components/shared/button";
43
+ export {
44
+ Button,
45
+ type ButtonProps,
46
+ type ButtonTooltipSide,
47
+ ButtonTooltipSuppress,
48
+ } from "./components/shared/button/button";
43
49
  export {
44
50
  type CalendarDayContext,
45
51
  type CalendarDayState,
@@ -296,7 +302,7 @@ export {
296
302
  BreadcrumbSeparator,
297
303
  } from "./components/ui/breadcrumb";
298
304
  export { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "./components/ui/bubble";
299
- export { Button, buttonVariants } from "./components/ui/button";
305
+ export { buttonVariants } from "./components/ui/button";
300
306
  export {
301
307
  ButtonGroup,
302
308
  ButtonGroupSeparator,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * ตีความผลของ action (server action / async handler) ให้เหมือนกันทุก component — ไม่พึ่ง next
3
+ */
4
+
5
+ /** ข้อความจาก error ที่ throw (ไม่มี = fallback) */
6
+ export function errorMessage(err: unknown, fallback: string): string {
7
+ return err instanceof Error && err.message ? err.message : fallback;
8
+ }
9
+
10
+ /** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
11
+ export function failedResult(result: unknown, fallback: string): string | null {
12
+ if (result && typeof result === "object" && "ok" in result && result.ok === false) {
13
+ const error = "error" in result ? result.error : undefined;
14
+ return typeof error === "string" && error ? error : fallback;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * error ที่ Next ใช้สั่งนำทาง (`redirect()`, `notFound()`, `forbidden()`/`unauthorized()`) — ไม่ใช่ความล้มเหลว
21
+ * server action ที่ redirect ทำให้ promise ฝั่ง client reject ด้วย error นี้ขณะที่ router นำทางให้แล้ว
22
+ * → ห้ามแสดงเป็น toast "NEXT_REDIRECT" (ดูจาก `digest` จึงไม่ต้อง import next)
23
+ */
24
+ export function isNavigationError(err: unknown): boolean {
25
+ if (!err || typeof err !== "object" || !("digest" in err)) return false;
26
+ const digest = (err as { digest: unknown }).digest;
27
+ return (
28
+ typeof digest === "string" &&
29
+ (digest.startsWith("NEXT_REDIRECT") ||
30
+ digest === "NEXT_NOT_FOUND" ||
31
+ digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"))
32
+ );
33
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * URL ที่นำทางด้วย script ได้อย่างปลอดภัย (`location.assign/replace`, `window.open`)
3
+ * — ช่องทางเหล่านี้ไม่มีตัวกัน `javascript:` แบบ `<a href>` ของ React หรือ router ของ Next
4
+ * รับเฉพาะ http(s) และ path ภายใน (relative); คืน URL ที่ resolve แล้ว หรือ null ถ้าไม่ปลอดภัย
5
+ * ไม่ได้กัน open redirect ไปโดเมนอื่น — ถ้าค่ามาจาก URL/ผู้ใช้ แอปต้องเช็กปลายทางเอง
6
+ */
7
+ export function safeNavigableUrl(href: string | null | undefined): string | null {
8
+ if (!href || typeof window === "undefined") return null;
9
+ try {
10
+ const url = new URL(href, window.location.href);
11
+ return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
package/src/styles.css CHANGED
@@ -297,25 +297,28 @@
297
297
  scrollbar-color: var(--scrollbar-thumb) transparent;
298
298
  }
299
299
 
300
- /* Global scrollbar styling */
301
- ::-webkit-scrollbar {
302
- width: 6px;
303
- height: 6px;
304
- }
305
- ::-webkit-scrollbar-track {
306
- background: transparent;
307
- }
308
- ::-webkit-scrollbar-thumb {
309
- background-color: var(--scrollbar-thumb);
310
- border-radius: 9999px;
311
- }
312
- ::-webkit-scrollbar-thumb:hover {
313
- background-color: var(--scrollbar-thumb-hover);
314
- }
300
+ /* Global scrollbar styling — อยู่ใน @layer base ให้ utility (no-scrollbar, scrollbar-thin, className ของแอป) ชนะได้
301
+ (เดิมไม่มี layer → `*{scrollbar-width:thin}` ทับ no-scrollbar ทุกที่ เช่นคอลัมน์ TimePicker แถบแท็บ) */
302
+ @layer base {
303
+ ::-webkit-scrollbar {
304
+ width: 6px;
305
+ height: 6px;
306
+ }
307
+ ::-webkit-scrollbar-track {
308
+ background: transparent;
309
+ }
310
+ ::-webkit-scrollbar-thumb {
311
+ background-color: var(--scrollbar-thumb);
312
+ border-radius: 9999px;
313
+ }
314
+ ::-webkit-scrollbar-thumb:hover {
315
+ background-color: var(--scrollbar-thumb-hover);
316
+ }
315
317
 
316
- * {
317
- scrollbar-width: thin;
318
- scrollbar-color: var(--scrollbar-thumb) transparent;
318
+ * {
319
+ scrollbar-width: thin;
320
+ scrollbar-color: var(--scrollbar-thumb) transparent;
321
+ }
319
322
  }
320
323
 
321
324
  html,