@goplusvn/core 0.1.83 → 0.1.85

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
@@ -1,3 +1,39 @@
1
+ ## 0.1.85 — Hộp thoại: gỡ bẫy class `sm:` khiến trang không ghi đè được
2
+
3
+ Sửa lỗi giao diện do 0.1.84 gây ra. `DialogContent` đổi mặc định thành
4
+ `p-4 sm:p-6` và `sm:max-w-lg`; `cn()` là `twMerge` mà twMerge **chỉ khử xung đột
5
+ trong cùng một variant**, nên `sm:p-6` / `sm:max-w-lg` sống sót khi trang truyền
6
+ `p-0` / `max-w-2xl`, rồi thắng ở ≥640px vì nằm trong media query.
7
+
8
+ Hậu quả trên app đã nâng (thingtodo): mọi hộp thoại CRUD bị bóp từ 672px xuống
9
+ 512px, cộng thêm 24px padding thừa nên đường kẻ header/footer thụt vào không
10
+ chạm mép, và nút X mặc định của Radix lộ ra (trước đó bị header sticky che).
11
+
12
+ **Đổi**
13
+
14
+ - `ui/primitives/dialog.tsx` — `p-4 sm:p-6` → `p-6`; `sm:max-w-lg` → `max-w-lg`;
15
+ bỏ `max-w-[calc(100%-2rem)]` thừa. Giữ nguyên phần chống tràn của 0.1.84
16
+ (`max-h-[calc(100dvh-2rem)]`, `overscroll-contain`, `w-[calc(100%-2rem)] sm:w-full`)
17
+ vì chúng không xung đột với class trang truyền vào.
18
+ - `ui/feedback/index.tsx` — `AlertDialogContent` dính đúng bẫy đó, sửa y hệt.
19
+ LƯU Ý cho app đang dùng: hộp thoại khai `max-w-sm` / `max-w-md` **không**
20
+ prefix từ nay hẹp đúng như đã khai, thay vì bị `sm:max-w-lg` kéo lên 512px.
21
+ - `crud/components/crud-dialog.tsx` — `formWidth` từng nội suy
22
+ `` max-w-${...} ``: Tailwind quét class TĨNH nên class đó không bao giờ được
23
+ sinh ra. Thay bằng bảng ánh xạ tĩnh. Thêm `w-full` (tràn viền trên điện thoại,
24
+ đi cùng `h-[100vh] rounded-none`) và ẩn tường minh nút X bằng
25
+ `[&>button.absolute]:hidden` — trước đây nó chỉ *vô tình* bị header che.
26
+ Đóng bằng "Huỷ" hoặc Esc; footer sticky nên "Huỷ" luôn thấy.
27
+ - `crud/components/crud-detail-dialog.tsx` — thêm `w-full` cho nhất quán.
28
+
29
+ **Quy tắc rút ra**: class mặc định trong primitive mà trang có thể muốn ghi đè
30
+ thì PHẢI để dạng không prefix. Muốn khác nhau theo bề rộng màn hình thì đặt ở
31
+ phía trang, đừng đặt ở mặc định.
32
+
33
+ Đã dựng thật với `core:link`: vinhhoa (từ 0.1.78) và tanloc-spartronics
34
+ (từ 0.1.83) `type-check` + `next build` đều xanh; không mất API công khai, không
35
+ đòi cột/bảng DB mới.
36
+
1
37
  # Changelog
2
38
 
3
39
  ## 0.1.81 — Cây workspace kiểu explorer, xoá/khôi phục lộ ra, thêm thành viên bằng dialog
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.83",
4
+ "version": "0.1.85",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -110,6 +110,7 @@
110
110
  "next": ">=14.0.0",
111
111
  "react": "^18.0.0 || ^19.0.0",
112
112
  "react-dom": "^18.0.0 || ^19.0.0",
113
+ "sonner": "^2.0.0",
113
114
  "vitest": ">=2"
114
115
  },
115
116
  "devDependencies": {
@@ -134,6 +135,7 @@
134
135
  "eslint-plugin-react-hooks": "^7.0.1",
135
136
  "globals": "16.5.0",
136
137
  "jsdom": "^27.2.0",
138
+ "sonner": "2.0.2",
137
139
  "tsup": "^8.5.1",
138
140
  "typescript": "^5.7.3",
139
141
  "typescript-eslint": "^8.50.1",
@@ -206,7 +208,6 @@
206
208
  "react-use": "17.5.1",
207
209
  "recharts": "^2.15.1",
208
210
  "shiki": "3.2.1",
209
- "sonner": "2.0.2",
210
211
  "swr": "^2.3.6",
211
212
  "tailwind-merge": "2.5.2",
212
213
  "vaul": "1.1.2",
@@ -269,7 +269,7 @@ export function CrudDetailDialog({
269
269
 
270
270
  return (
271
271
  <Dialog open={open} onOpenChange={onOpenChange} modal={true}>
272
- <DialogContent className="max-w-2xl max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0 gap-0">
272
+ <DialogContent className="w-full max-w-2xl max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0 gap-0">
273
273
  {/* Header */}
274
274
  <DialogHeader className="bg-background border-b px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 text-left space-y-0 shrink-0">
275
275
  <div className="flex items-center gap-2 sm:gap-3 min-w-0">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import { CheckCircle2, Loader2 } from "lucide-react";
5
+ import { cn } from "../../utils";
5
6
 
6
7
  import type { EntityConfig } from "../../types";
7
8
 
@@ -16,6 +17,22 @@ import { DynamicIcon } from "../../ui";
16
17
  import { CrudForm } from "./crud-form";
17
18
  import { useCrudConfig } from "./crud-context";
18
19
 
20
+ // Tailwind quét class TĨNH — `max-w-${...}` sẽ KHÔNG được sinh ra, hộp thoại
21
+ // mất luôn giới hạn bề rộng. Phải liệt kê nguyên chuỗi.
22
+ const FORM_WIDTH_CLASS: Record<
23
+ NonNullable<EntityConfig["formWidth"]>,
24
+ string
25
+ > = {
26
+ sm: "max-w-sm",
27
+ md: "max-w-md",
28
+ lg: "max-w-lg",
29
+ xl: "max-w-xl",
30
+ "2xl": "max-w-2xl",
31
+ "3xl": "max-w-3xl",
32
+ "4xl": "max-w-4xl",
33
+ "5xl": "max-w-5xl",
34
+ };
35
+
19
36
  interface CrudDialogProps {
20
37
  open: boolean;
21
38
  onOpenChange: (open: boolean) => void;
@@ -192,7 +209,16 @@ export function CrudDialog({
192
209
  return (
193
210
  <Dialog open={open} onOpenChange={onOpenChange} modal={true}>
194
211
  <DialogContent
195
- className="max-w-2xl max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0"
212
+ className={cn(
213
+ // `w-full` là CỐ Ý: ghi đè mặc định `w-[calc(100%-2rem)]` của
214
+ // DialogContent để hộp thoại tràn viền trên điện thoại (đi cùng
215
+ // h-[100vh] + rounded-none), desktop vẫn bị max-w-* chặn lại.
216
+ "w-full max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0",
217
+ // Header dính (sticky z-10, nền đục) che mất nút X mặc định của
218
+ // Radix — ẩn hẳn cho tường minh. Đóng bằng "Huỷ" hoặc Esc.
219
+ "[&>button.absolute]:hidden",
220
+ FORM_WIDTH_CLASS[config.formWidth ?? "2xl"],
221
+ )}
196
222
  onInteractOutside={(e) => {
197
223
  // Prevent closing dialog when submitting or clicking on Popover
198
224
  if (isSubmitting || showSuccess) {
@@ -484,9 +484,9 @@ export function CrudFieldRenderer({
484
484
  name={field.name}
485
485
  render={({ field: formField }) => (
486
486
  <FormItem>
487
- {field.label && <FormLabel>{field.label}</FormLabel>}
487
+ {field.label && <FormLabel className="font-semibold">{field.label}</FormLabel>}
488
488
  <FormControl>
489
- <div className="flex items-center gap-2 pt-2">
489
+ <div className="flex items-center h-10">
490
490
  <Switch
491
491
  checked={
492
492
  field.type === "switch"
@@ -46,10 +46,18 @@ function resolveIncludedRelationLabel(
46
46
  row: Record<string, unknown> | undefined,
47
47
  ): string | undefined {
48
48
  const name = field?.name;
49
- if (!field?.dataSource || !row || !name || !name.endsWith("Id")) {
49
+ if (!field?.dataSource || !row || !name) {
50
50
  return undefined;
51
51
  }
52
- const relationKey = name.slice(0, -2);
52
+ let relationKey = "";
53
+ if (name.endsWith("Id")) {
54
+ relationKey = name.slice(0, -2);
55
+ } else if (name.endsWith("_id")) {
56
+ relationKey = name.slice(0, -3);
57
+ } else {
58
+ return undefined;
59
+ }
60
+
53
61
  if (!relationKey) return undefined;
54
62
  const rel = row[relationKey];
55
63
  if (!rel || typeof rel !== "object" || Array.isArray(rel)) return undefined;
@@ -147,6 +147,25 @@ export function useMobile(): boolean {
147
147
  return useMediaQuery("(max-width: 768px)");
148
148
  }
149
149
 
150
+ /**
151
+ * Mốc "màn hình hẹp" RIÊNG cho sidebar — cố ý KHÁC `useMobile()`.
152
+ *
153
+ * Sidebar desktop hiện từ `lg:` (≥1024px). Nếu hook quyết định off-canvas lại
154
+ * dùng mốc 768px thì sinh ra hai lỗi:
155
+ * 1. Vùng chết 769–1023px (iPad dọc 820, iPad Pro 11" 834, Surface 912):
156
+ * sidebar desktop đã render nhưng KHÔNG nút nào thu gọn được nó — nút
157
+ * `SidebarTrigger` là `lg:` còn `ToggleMobileSidebar` chỉ hiện khi hẹp.
158
+ * Sidebar 16rem ăn cứng 256px của một màn ~800px.
159
+ * 2. Đúng 768px (iPad Mini dọc) thì `max-width:768px` VÀ `min-width:768px`
160
+ * cùng đúng ⇒ Sheet mobile và sidebar desktop mount CÙNG LÚC, đè lên nhau.
161
+ *
162
+ * Dùng 1023.98px để khớp đúng biên `lg:` của Tailwind và tránh off-by-one.
163
+ * `useMobile()` giữ nguyên 768px — đã là API công khai, app khác đang dựa vào.
164
+ */
165
+ export function useSidebarMobile(): boolean {
166
+ return useMediaQuery("(max-width: 1023.98px)");
167
+ }
168
+
150
169
  // ============================================================================
151
170
  // Clipboard Hook
152
171
  // ============================================================================
@@ -336,6 +336,7 @@ export interface EntityConfig {
336
336
  codeGeneration?: CodeGenerationConfig;
337
337
  formSections?: FormSection[];
338
338
  formMode?: FormModeConfig;
339
+ formWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
339
340
  rowActions?: RowAction[];
340
341
  include?: string[];
341
342
  }
@@ -89,7 +89,13 @@ export function AlertDialogContent({
89
89
  <AlertDialogPrimitive.Content
90
90
  data-slot="alert-dialog-content"
91
91
  className={cn(
92
- "fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%-2rem)] grid bg-background translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
92
+ "fixed top-[50%] left-[50%] z-50 w-[calc(100%_-_2rem)] sm:w-full grid bg-background translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-lg",
93
+ // KHÔNG để `sm:p-6` / `sm:max-w-lg` làm mặc định: twMerge chỉ khử xung
94
+ // đột trong cùng variant, nên trang truyền `p-0` / `max-w-sm` sẽ bị
95
+ // class `sm:` đè ngược ở ≥640px (đúng lỗi 0.1.84 của DialogContent).
96
+ // Cùng lý do với DialogContent: bị neo giữa màn hình nên phải chặn
97
+ // chiều cao, không thì mô tả dài sẽ tràn ra ngoài và không cuộn được.
98
+ "max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
93
99
  className,
94
100
  )}
95
101
  {...props}
@@ -1,5 +1,18 @@
1
1
  "use client";
2
2
 
3
+ /**
4
+ * `sonner` là PEER dependency của core, cố ý — không phải `dependencies`.
5
+ *
6
+ * `toast()` đẩy vào một store cấp module, còn `<Toaster>` đọc chính store đó.
7
+ * Nếu core tự mang theo bản sonner riêng thì app và core có thể nạp HAI bản
8
+ * khác nhau ⇒ hai store ⇒ `toast()` gọi bên app không bao giờ tới `<Toaster>`
9
+ * dựng bởi core: không nổ lỗi, chỉ im lặng không hiện gì. Đúng cảnh đã gặp khi
10
+ * consumer chạy `pnpm core:link` (checkout core mang node_modules riêng).
11
+ *
12
+ * Để peer thì bản sonner LUÔN là bản của app — một store duy nhất, kể cả khi
13
+ * link. Cả ba app đang dùng (vinhhoa, thingtodo, tanloc-spartronics) đều đã tự
14
+ * khai `sonner` trong dependencies nên đây là thay đổi không phá vỡ ai.
15
+ */
3
16
  import { Toaster as Sonner } from "sonner";
4
17
 
5
18
  import type { ComponentProps } from "react";
@@ -167,7 +167,17 @@ export function PageTabs({
167
167
 
168
168
  return (
169
169
  <div className={cn("w-full", variant === "header" && "py-0", className)}>
170
- <ScrollArea className={cn("w-full", variant === "header" && "h-8")}>
170
+ {/* orientation="horizontal": ScrollBar mặc định là "vertical" nên dải tab
171
+ cuộn được bằng cảm ứng nhưng trên desktop KHÔNG có thanh cuộn nào —
172
+ người dùng tưởng các tab bị cắt mất. Bar bóp còn 6px để không đè
173
+ lên chữ trong biến thể header cao 32px. */}
174
+ <ScrollArea
175
+ orientation="horizontal"
176
+ className={cn(
177
+ "w-full [&_[data-slot=scroll-area-scrollbar]]:h-1.5",
178
+ variant === "header" && "h-8",
179
+ )}
180
+ >
171
181
  <div
172
182
  className={cn(
173
183
  "flex items-end gap-0 px-2 py-0.5",
@@ -26,12 +26,6 @@ import {
26
26
  DropdownMenuSeparator,
27
27
  DropdownMenuTrigger,
28
28
  } from "../primitives/dropdown-menu";
29
- import {
30
- Tooltip,
31
- TooltipContent,
32
- TooltipProvider,
33
- TooltipTrigger,
34
- } from "../primitives/client";
35
29
 
36
30
  interface ExtendedUser {
37
31
  id: string;
@@ -86,34 +80,26 @@ export function UserDropdown({
86
80
 
87
81
  return (
88
82
  <DropdownMenu>
89
- <TooltipProvider>
90
- <Tooltip>
91
- <TooltipTrigger asChild>
92
- <DropdownMenuTrigger asChild>
93
- <Button
94
- variant="outline"
95
- size="icon"
96
- className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
97
- aria-label="User"
98
- >
99
- <Avatar className="size-6">
100
- <AvatarImage
101
- src={user?.avatar || undefined}
102
- alt={user?.name || ""}
103
- className="object-cover"
104
- />
105
- <AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
106
- {user?.name && getInitials(user.name)}
107
- </AvatarFallback>
108
- </Avatar>
109
- </Button>
110
- </DropdownMenuTrigger>
111
- </TooltipTrigger>
112
- <TooltipContent>
113
- <p>Profile</p>
114
- </TooltipContent>
115
- </Tooltip>
116
- </TooltipProvider>
83
+ <DropdownMenuTrigger asChild>
84
+ <Button
85
+ variant="outline"
86
+ size="icon"
87
+ className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
88
+ aria-label="User"
89
+ title="Profile"
90
+ >
91
+ <Avatar className="size-6">
92
+ <AvatarImage
93
+ src={user?.avatar || undefined}
94
+ alt={user?.name || ""}
95
+ className="object-cover"
96
+ />
97
+ <AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
98
+ {user?.name && getInitials(user.name)}
99
+ </AvatarFallback>
100
+ </Avatar>
101
+ </Button>
102
+ </DropdownMenuTrigger>
117
103
 
118
104
  <DropdownMenuContent
119
105
  className="w-56 p-1 bg-background/80 backdrop-blur-xl border border-border/40 shadow-xl rounded-xl animate-in fade-in zoom-in-95 duration-200"
@@ -28,7 +28,11 @@ export function VerticalLayoutHeader({
28
28
 
29
29
  return (
30
30
  <header className="sticky top-0 z-50 w-full bg-background/95 border-b border-border/50 backdrop-blur supports-[backdrop-filter]:bg-background/80">
31
- <div className="container flex h-11 justify-between items-center gap-0">
31
+ {/* KHÔNG dùng `container`: max-width của nó bám theo VIEWPORT, không theo
32
+ phần tử cha. Cha ở đây là SidebarInset = viewport − bề rộng sidebar, và
33
+ SidebarInset có `overflow-hidden`, nên ở 768–1024px hàng này rộng hơn
34
+ cha và cụm nút bên phải (chuông, customizer, user) bị cắt mất. */}
35
+ <div className="flex w-full h-11 justify-between items-center gap-1 px-2 sm:px-3">
32
36
  {/* Left: sidebar toggles */}
33
37
  <div className="flex items-center gap-2 flex-shrink-0">
34
38
  <ToggleMobileSidebar />
@@ -46,9 +50,12 @@ export function VerticalLayoutHeader({
46
50
 
47
51
  {/* Right: user actions */}
48
52
  <div className="flex items-center gap-2 flex-shrink-0">
49
- <div className="hidden sm:flex items-center gap-2">
53
+ <div className="flex items-center gap-1 sm:gap-2">
50
54
  {notificationSlot ?? <NotificationDropdown dictionary={dictionary} />}
51
- <FullScreenToggle />
55
+ {/* Toàn màn hình vô nghĩa trên thiết bị cảm ứng — ẩn dưới lg. */}
56
+ <span className="hidden lg:inline-flex">
57
+ <FullScreenToggle />
58
+ </span>
52
59
  </div>
53
60
  <Customizer
54
61
  trigger={
@@ -37,12 +37,15 @@ export function VerticalLayout({
37
37
  searchResults={searchResults}
38
38
  searchLoading={searchLoading}
39
39
  />
40
- <SidebarInset className="w-full min-w-0 overflow-hidden h-screen flex flex-col">
40
+ <SidebarInset className="w-full min-w-0 overflow-hidden h-dvh flex flex-col">
41
41
  <VerticalLayoutHeader
42
42
  dictionary={dictionary}
43
43
  notificationSlot={notificationSlot}
44
44
  />
45
- <main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4 overflow-auto">
45
+ {/* `h-dvh` SidebarInset (không phải `h-screen`): trên Safari/Chrome
46
+ mobile 100vh tính CẢ thanh URL co giãn, nên đáy trang — phân trang,
47
+ footer, nút Lưu — nằm khuất dưới thanh trình duyệt. */}
48
+ <main className="w-full mx-auto flex-1 min-h-0 px-3 sm:px-4 md:px-6 xl:px-8 py-3 md:py-4 overflow-auto">
46
49
  {children}
47
50
  </main>
48
51
  <Footer />
@@ -40,7 +40,20 @@ const DialogContent = React.forwardRef<
40
40
  <DialogPrimitive.Content
41
41
  ref={ref}
42
42
  className={cn(
43
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
43
+ "fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
44
+ // Hộp thoại bị neo `top-1/2 -translate-y-1/2`, nên nếu KHÔNG chặn chiều
45
+ // cao thì nội dung cao hơn màn hình sẽ tràn ra CẢ trên lẫn dưới và
46
+ // không cuộn được — hỏng trên điện thoại và trên tablet xoay ngang.
47
+ // Đây là mặc định: trang nào tự khai `max-h-*` / `overflow-*` thì
48
+ // class của trang đứng sau trong cn() nên vẫn thắng.
49
+ "max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
50
+ // Bề rộng: chừa 1rem mỗi bên trên màn hẹp thay vì dán sát mép.
51
+ // CẢNH BÁO (đã dính 0.1.84): mọi class mặc định ở đây mà trang có thể
52
+ // muốn ghi đè PHẢI để dạng KHÔNG prefix. `cn()` = twMerge chỉ khử xung
53
+ // đột trong CÙNG một variant — `sm:max-w-lg`/`sm:p-6` sẽ SỐNG SÓT khi
54
+ // trang truyền `max-w-2xl`/`p-0`, rồi thắng ở ≥640px vì nằm trong media
55
+ // query. Đó chính là lỗi bóp CrudDialog còn 512px + padding kép.
56
+ "w-[calc(100%_-_2rem)] sm:w-full max-w-lg",
44
57
  className,
45
58
  )}
46
59
  {...props}
@@ -6,7 +6,7 @@ import type { VariantProps } from "class-variance-authority";
6
6
  import { cva } from "class-variance-authority";
7
7
  import { PanelLeft } from "lucide-react";
8
8
 
9
- import { useMobile } from "../../hooks"; // Exported from core/hooks
9
+ import { useSidebarMobile } from "../../hooks"; // Exported from core/hooks
10
10
  import { cn } from "../../utils";
11
11
  import { Button } from "./button";
12
12
  import { Sheet, SheetContent, SheetTitle } from "../feedback";
@@ -69,7 +69,10 @@ const SidebarProvider = React.forwardRef<
69
69
  },
70
70
  ref,
71
71
  ) => {
72
- const isMobile = useMobile();
72
+ // Mốc sidebar là 1023.98px (khớp `lg:`), KHÔNG phải `useMobile()` 768px —
73
+ // xem chú thích ở useSidebarMobile: 768px để hở vùng chết tablet 769–1023px
74
+ // và làm hai sidebar cùng mount tại đúng 768px.
75
+ const isMobile = useSidebarMobile();
73
76
  const [openMobile, setOpenMobile] = React.useState(false);
74
77
  // Hover expand state
75
78
  const [hoverOpen, setHoverOpen] = React.useState(false);
@@ -266,7 +269,7 @@ const Sidebar = React.forwardRef<
266
269
  <div
267
270
  ref={ref}
268
271
  className={cn(
269
- "group peer hidden md:block transition-colors duration-200",
272
+ "group peer hidden lg:block transition-colors duration-200",
270
273
  // Blue bg + white text when collapsed, white bg + dark text when expanded/hover
271
274
  state === "collapsed" && !isHoverExpanded
272
275
  ? "text-sidebar-foreground"
@@ -294,7 +297,7 @@ const Sidebar = React.forwardRef<
294
297
  {/* Sidebar Content - expands on hover */}
295
298
  <div
296
299
  className={cn(
297
- "duration-200 fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-linear md:flex",
300
+ "duration-200 fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-linear lg:flex",
298
301
  side === "left"
299
302
  ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
300
303
  : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -402,14 +405,14 @@ const SidebarInset = React.forwardRef<
402
405
  // Inset: content is a card floating with a uniform m-2 gap on ALL sides
403
406
  // (no ml-0 override) so there's a small gap between the sidebar and the
404
407
  // content/header instead of them touching.
405
- "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
408
+ "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] lg:peer-data-[variant=inset]:m-2 lg:peer-data-[variant=inset]:rounded-xl lg:peer-data-[variant=inset]:shadow",
406
409
  // Floating: the content is full-bleed (no card), and its LEFT edge is
407
410
  // the side that meets the floating sidebar. Give it a small left gap
408
411
  // (ml-2) so it doesn't touch the sidebar, and round both left corners
409
412
  // (top-left clips the sticky header, bottom-left clips the footer via
410
413
  // overflow-hidden) so the content hugs the floating sidebar with soft
411
414
  // curves instead of hard 90° corners. Right edge stays flush.
412
- "md:peer-data-[variant=floating]:ml-2 md:peer-data-[variant=floating]:rounded-l-xl",
415
+ "lg:peer-data-[variant=floating]:ml-2 lg:peer-data-[variant=floating]:rounded-l-xl",
413
416
  className,
414
417
  )}
415
418
  {...props}