@comitor/ui 0.5.1 → 0.6.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/README.md +3 -2
- package/dist/components/composite/data-table.d.ts +7 -0
- package/dist/components/composite/data-table.js +5 -6
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/form-field.js +5 -2
- package/dist/components/composite/form-field.js.map +1 -1
- package/dist/components/ui/command.js +1 -1
- package/dist/components/ui/command.js.map +1 -1
- package/dist/components/ui/context-menu.js +4 -4
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/dropdown-menu.js +4 -4
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/field.js +6 -12
- package/dist/components/ui/field.js.map +1 -1
- package/dist/components/ui/input-group.js +21 -3
- package/dist/components/ui/input-group.js.map +1 -1
- package/dist/components/ui/item.js +1 -1
- package/dist/components/ui/item.js.map +1 -1
- package/dist/components/ui/menubar.js +4 -4
- package/dist/components/ui/menubar.js.map +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/table.js +2 -2
- package/dist/components/ui/table.js.map +1 -1
- package/dist/shell/comitor-lockup.js +11 -6
- package/dist/shell/comitor-lockup.js.map +1 -1
- package/dist/shell/comitor-logo.d.ts +4 -3
- package/dist/shell/comitor-logo.js +8 -1
- package/dist/shell/comitor-logo.js.map +1 -1
- package/dist/shell/display-axis-constants.d.ts +35 -0
- package/dist/shell/display-axis-constants.js +27 -0
- package/dist/shell/display-axis-constants.js.map +1 -0
- package/dist/shell/display-axis-controls.d.ts +51 -0
- package/dist/shell/display-axis-controls.js +70 -0
- package/dist/shell/display-axis-controls.js.map +1 -0
- package/dist/shell/display-axis-provider.d.ts +40 -0
- package/dist/shell/display-axis-provider.js +101 -0
- package/dist/shell/display-axis-provider.js.map +1 -0
- package/dist/shell/index.d.ts +3 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/index.js.map +1 -1
- package/dist/shell/shell-context.d.ts +9 -1
- package/dist/shell/shell-context.js +14 -4
- package/dist/shell/shell-context.js.map +1 -1
- package/dist/shell/sidebar-menu-item.js +1 -1
- package/dist/shell/sidebar-menu-item.js.map +1 -1
- package/package.json +1 -1
- package/styles.css +82 -1
|
@@ -4,6 +4,7 @@ import NextLinkModule from "next/link.js";
|
|
|
4
4
|
import { usePathname } from "next/navigation.js";
|
|
5
5
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
6
6
|
import { TooltipProvider } from "../components/ui/tooltip.js";
|
|
7
|
+
import { layout } from "../tokens.js";
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_SHELL_LABELS
|
|
9
10
|
} from "./types.js";
|
|
@@ -23,6 +24,11 @@ function useShell() {
|
|
|
23
24
|
function sidebarStorageKey(workspaceId) {
|
|
24
25
|
return `comitor-sidebar:${workspaceId ?? "default"}`;
|
|
25
26
|
}
|
|
27
|
+
function isTabletWidth() {
|
|
28
|
+
if (typeof window === "undefined") return false;
|
|
29
|
+
const { mobile, tablet } = layout.breakpoints;
|
|
30
|
+
return window.matchMedia(`(min-width: ${mobile}px) and (max-width: ${tablet - 1}px)`).matches;
|
|
31
|
+
}
|
|
26
32
|
function readStoredCollapsed(workspaceId) {
|
|
27
33
|
if (typeof window === "undefined") return null;
|
|
28
34
|
try {
|
|
@@ -53,6 +59,7 @@ function ShellProvider({
|
|
|
53
59
|
user = null,
|
|
54
60
|
onLogout,
|
|
55
61
|
defaultSidebarCollapsed = false,
|
|
62
|
+
collapseSidebarOnTablet = true,
|
|
56
63
|
persistSidebarState = true,
|
|
57
64
|
enableShortcuts = true,
|
|
58
65
|
labels: labelsProp,
|
|
@@ -76,10 +83,13 @@ function ShellProvider({
|
|
|
76
83
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
|
77
84
|
const [appLauncherOpen, setAppLauncherOpen] = useState(false);
|
|
78
85
|
useEffect(() => {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
const stored = persistSidebarState ? readStoredCollapsed(currentWorkspace?.id) : null;
|
|
87
|
+
if (stored !== null) {
|
|
88
|
+
setSidebarCollapsed(stored);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (collapseSidebarOnTablet && isTabletWidth()) setSidebarCollapsed(true);
|
|
92
|
+
}, [persistSidebarState, currentWorkspace?.id, collapseSidebarOnTablet]);
|
|
83
93
|
const persistCollapsed = useCallback(
|
|
84
94
|
(collapsed) => {
|
|
85
95
|
if (!persistSidebarState || typeof window === "undefined") return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/shell-context.tsx"],"sourcesContent":["\"use client\";\n\n/*\n * ĐUÔI `.js` LÀ CHỦ ĐÍCH, ĐỪNG BỎ.\n *\n * Gói `next` KHÔNG khai `exports` map, nên `next/link` chỉ là đường dẫn file. Bundler\n * (webpack/Turbopack) tự thêm đuôi rồi trỏ đúng `node_modules/next/link.js`; Node ESM thuần thì\n * KHÔNG — nó ném `ERR_MODULE_NOT_FOUND: Cannot find module .../next/link` kèm gợi ý\n * \"Did you mean next/link.js?\". Hậu quả: `node -e \"await import('@comitor/ui/shell')\"` chết ngay\n * lúc nạp, tức là mọi smoke-test entry của CI đều mù với riêng entry shell.\n *\n * Ghi thẳng `next/link.js` / `next/navigation.js` là CÙNG MỘT file mà bundler vẫn resolve tới\n * (đã đối chiếu: `next` không có `resolve.alias` cho hai specifier này trong\n * `dist/build/create-compiler-aliases.js`), nhưng Node cũng nạp được.\n */\nimport NextLinkModule from \"next/link.js\";\nimport { usePathname } from \"next/navigation.js\";\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { TooltipProvider } from \"../components/ui/tooltip.js\";\nimport {\n type AppDescriptor,\n DEFAULT_SHELL_LABELS,\n type NavGroup,\n type ShellLabels,\n type ShellLinkComponent,\n type ShellLinkProps,\n type ShellNav,\n type ShellUser,\n type Workspace\n} from \"./types.js\";\nimport { getEntitledApps, type NavSearchParams, resolveCurrentApp, toNavGroups, toSearchParams } from \"./utils.js\";\n\n/* ────────────────────────────────────────────────────────────────────────────\n Link mặc định\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Chuẩn hoá interop của `next/link.js`.\n *\n * Hai môi trường trả về hai hình dạng khác nhau cho cùng một file CJS:\n * - bundler → đã interop sẵn, biến này CHÍNH LÀ component `Link`\n * - Node ESM → biến này là namespace `{ default: Link, useLinkStatus }`\n * `.default ?? chính nó` đúng ở cả hai (component `Link` không có thuộc tính `default`).\n */\nconst NextLink = ((NextLinkModule as unknown as { default?: ShellLinkComponent }).default ??\n NextLinkModule) as ShellLinkComponent;\n\n/**\n * Bọc `next/link` thay vì truyền thẳng: hình dạng props của NextLink rộng hơn\n * `ShellLinkProps` (href nhận cả UrlObject), bọc lại cho khớp type và để chỗ\n * thay adapter router khác về sau.\n */\nfunction DefaultShellLink({ href, children, ...props }: ShellLinkProps) {\n return (\n <NextLink href={href} {...props}>\n {children}\n </NextLink>\n );\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Context\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellContextValue {\n /* Trục 1 — workspace */\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /* Trục 2 — app */\n apps: AppDescriptor[];\n currentApp: AppDescriptor | null;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /* Điều hướng trong app hiện tại */\n nav: NavGroup[];\n pathname: string;\n /**\n * Query hiện tại đã chuẩn hoá, hoặc `null` khi app không truyền.\n * `null` KHÁC rỗng: rỗng nghĩa là \"URL không có query\", `null` nghĩa là \"shell không biết\"\n * — và active matching xử lý hai ca đó khác nhau (xem `isNavItemActive`).\n */\n searchParams: URLSearchParams | null;\n LinkComponent: ShellLinkComponent;\n onNavigate?: (href: string) => void;\n\n /* Người dùng */\n user: ShellUser | null;\n onLogout?: () => void;\n\n /* Trạng thái khung */\n sidebarCollapsed: boolean;\n setSidebarCollapsed: (collapsed: boolean) => void;\n toggleSidebar: () => void;\n mobileMenuOpen: boolean;\n setMobileMenuOpen: (open: boolean) => void;\n commandPaletteOpen: boolean;\n setCommandPaletteOpen: (open: boolean) => void;\n appLauncherOpen: boolean;\n setAppLauncherOpen: (open: boolean) => void;\n\n /* Chuỗi hiển thị (đã trộn mặc định tiếng Việt với override của app) */\n labels: ShellLabels;\n}\n\nconst ShellContext = createContext<ShellContextValue | undefined>(undefined);\n\n/** Truy cập state của shell — phải gọi bên trong `<ShellProvider>`. */\nexport function useShell(): ShellContextValue {\n const context = useContext(ShellContext);\n if (!context) {\n throw new Error(\"useShell phải được dùng bên trong <ShellProvider> (hoặc <AppShell>).\");\n }\n return context;\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Ghi nhớ trạng thái thu gọn sidebar\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Khoá localStorage theo workspace — mỗi workspace nhớ riêng trạng thái sidebar\n * (spec §7.6: KHÔNG dùng cookie `sidebar_state` của shadcn để tránh hai nguồn sự thật).\n */\nfunction sidebarStorageKey(workspaceId: string | undefined): string {\n return `comitor-sidebar:${workspaceId ?? \"default\"}`;\n}\n\nfunction readStoredCollapsed(workspaceId: string | undefined): boolean | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.localStorage.getItem(sidebarStorageKey(workspaceId));\n if (raw === \"1\") return true;\n if (raw === \"0\") return false;\n return null;\n } catch {\n // Safari private mode / storage bị chặn — coi như chưa có giá trị lưu.\n return null;\n }\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Provider\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellProviderProps {\n /** Trục 1 — danh sách workspace của user. Rỗng/1 phần tử thì WorkspaceSwitcher tự ẩn danh sách. */\n workspaces?: Workspace[];\n /** Workspace đang mở; bỏ trống → lấy phần tử đầu. */\n currentWorkspaceId?: string;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /** Trục 2 — danh sách app của hệ sinh thái (kể cả app chưa mở khoá). */\n apps?: AppDescriptor[];\n /** App đang mở; bỏ trống → suy từ `pathname` (khớp prefix dài nhất). */\n currentAppId?: string;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /** Menu của app hiện tại — `NavItem[]` hoặc `NavGroup[]`. */\n nav?: ShellNav;\n /**\n * Ghi đè pathname (Storybook, app không-Next). Bỏ trống → dùng `usePathname()`\n * của next/navigation (trả `null` khi không có App Router → coi như \"\").\n */\n pathname?: string;\n /**\n * Query hiện tại — CHỈ cần khi menu có mục phân biệt nhau bằng query\n * (`/cai-dat?tab=chung` ⇄ `/cai-dat?tab=thanh-vien`). Bỏ trống thì mọi mục cùng đường dẫn\n * cùng sáng.\n *\n * Nhận `useSearchParams()` của Next, chuỗi `\"?tab=x\"`, hoặc object `searchParams` của\n * Server Component.\n *\n * ⚠ Shell KHÔNG tự gọi `useSearchParams()`: hook đó ép trang bọc `<Suspense>` và đẩy trang\n * sang render phía client khi build tĩnh — cái giá đó phải do app tự chọn, không phải do\n * việc lỡ import shell. Truyền vào từ một client component đã có sẵn boundary:\n *\n * ```tsx\n * \"use client\";\n * const searchParams = useSearchParams();\n * return <AppShell searchParams={searchParams} …>{children}</AppShell>;\n * ```\n */\n searchParams?: NavSearchParams | null;\n /** Component link thay `next/link` (app không-Next). */\n LinkComponent?: ShellLinkComponent;\n /** Gọi sau mỗi lần người dùng bấm một mục điều hướng (analytics, đóng panel…). */\n onNavigate?: (href: string) => void;\n\n user?: ShellUser | null;\n onLogout?: () => void;\n\n /** Trạng thái thu gọn ban đầu (SSR). Sau khi mount, localStorage của workspace thắng. */\n defaultSidebarCollapsed?: boolean;\n /** Tắt ghi nhớ localStorage (Storybook, chế độ kiosk). */\n persistSidebarState?: boolean;\n /** Tắt bộ phím tắt toàn cục. */\n enableShortcuts?: boolean;\n\n /** Override chuỗi hiển thị (mặc định tiếng Việt). */\n labels?: Partial<ShellLabels>;\n\n children: ReactNode;\n}\n\n/**\n * Provider của app shell Comitor: giữ hai trục điều hướng (workspace × app),\n * trạng thái khung (sidebar/mobile/command palette/app launcher), phím tắt toàn cục,\n * và bọc sẵn `TooltipProvider`.\n *\n * KHÔNG dùng `useRouter()` của next/navigation: hook đó *ném lỗi* khi thiếu App Router\n * (Storybook, test). Mọi điều hướng chủ động là việc của app qua callback\n * `onWorkspaceChange` / `onAppSelect` / `onNavigate`; link tĩnh đi qua `LinkComponent`.\n */\nexport function ShellProvider({\n workspaces = [],\n currentWorkspaceId,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps = [],\n currentAppId,\n onAppSelect,\n onUpsell,\n nav,\n pathname: pathnameProp,\n searchParams: searchParamsProp,\n LinkComponent = DefaultShellLink,\n onNavigate,\n user = null,\n onLogout,\n defaultSidebarCollapsed = false,\n persistSidebarState = true,\n enableShortcuts = true,\n labels: labelsProp,\n children\n}: ShellProviderProps) {\n // `usePathname()` trả null khi không có App Router → fallback chuỗi rỗng thay vì crash.\n const nextPathname = usePathname();\n const pathname = pathnameProp ?? nextPathname ?? \"\";\n\n // Chuẩn hoá MỘT lần ở đây thay vì mỗi lần `isNavItemActive` chạy: prop có thể là chuỗi\n // hoặc object, mà mỗi lần dựng `URLSearchParams` mới là một identity mới → mọi memo bên\n // dưới hỏng. Khoá memo là chính prop app truyền vào.\n const searchParams = useMemo(() => toSearchParams(searchParamsProp), [searchParamsProp]);\n\n const currentWorkspace = useMemo(\n () => workspaces.find((workspace) => workspace.id === currentWorkspaceId) ?? workspaces[0] ?? null,\n [workspaces, currentWorkspaceId]\n );\n\n const currentApp = useMemo(() => {\n if (currentAppId) return apps.find((app) => app.id === currentAppId) ?? null;\n return resolveCurrentApp(apps, pathname);\n }, [apps, currentAppId, pathname]);\n\n const navGroups = useMemo(() => toNavGroups(nav), [nav]);\n const labels = useMemo<ShellLabels>(() => ({ ...DEFAULT_SHELL_LABELS, ...labelsProp }), [labelsProp]);\n\n const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultSidebarCollapsed);\n const [mobileMenuOpen, setMobileMenuOpen] = useState(false);\n const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);\n const [appLauncherOpen, setAppLauncherOpen] = useState(false);\n\n // Nạp trạng thái đã lưu SAU khi mount (đọc localStorage lúc render đầu sẽ lệch hydration).\n useEffect(() => {\n if (!persistSidebarState) return;\n const stored = readStoredCollapsed(currentWorkspace?.id);\n if (stored !== null) setSidebarCollapsed(stored);\n }, [persistSidebarState, currentWorkspace?.id]);\n\n const persistCollapsed = useCallback(\n (collapsed: boolean) => {\n if (!persistSidebarState || typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(sidebarStorageKey(currentWorkspace?.id), collapsed ? \"1\" : \"0\");\n } catch {\n // Bỏ qua: không ghi được localStorage thì trạng thái chỉ sống trong phiên.\n }\n },\n [persistSidebarState, currentWorkspace?.id]\n );\n\n const applySidebarCollapsed = useCallback(\n (collapsed: boolean) => {\n setSidebarCollapsed(collapsed);\n persistCollapsed(collapsed);\n },\n [persistCollapsed]\n );\n\n const toggleSidebar = useCallback(() => {\n setSidebarCollapsed((prev) => {\n const next = !prev;\n persistCollapsed(next);\n return next;\n });\n }, [persistCollapsed]);\n\n // Phím tắt toàn cục (§7.4)\n useEffect(() => {\n if (!enableShortcuts) return;\n\n const entitledApps = getEntitledApps(apps);\n\n const handleKeyDown = (event: KeyboardEvent) => {\n const target = event.target;\n // Đang gõ trong ô nhập → nhường phím cho ô nhập.\n const isTyping =\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement ||\n (target instanceof HTMLElement && target.isContentEditable);\n\n if (event.key === \"Escape\") {\n setCommandPaletteOpen(false);\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n return;\n }\n\n if (isTyping) return;\n\n const mod = event.metaKey || event.ctrlKey;\n const key = event.key.toLowerCase();\n\n // Cmd/Ctrl + Shift + K — AppLauncher (phải xét TRƯỚC Cmd+K vì cùng phím \"k\")\n if (mod && event.shiftKey && key === \"k\") {\n event.preventDefault();\n setAppLauncherOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + K — Command Palette\n if (mod && !event.shiftKey && key === \"k\") {\n event.preventDefault();\n setCommandPaletteOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + B — thu gọn/mở sidebar\n if (mod && !event.shiftKey && key === \"b\") {\n event.preventDefault();\n toggleSidebar();\n return;\n }\n\n // Alt + 1…9 — nhảy tới app thứ n đã được cấp quyền.\n // Dùng `event.code` chứ không `event.key`: trên macOS Alt+1 cho ra \"¡\".\n if (event.altKey && !event.ctrlKey && !event.metaKey) {\n const match = /^Digit([1-9])$/.exec(event.code);\n if (match?.[1]) {\n const index = Number.parseInt(match[1], 10) - 1;\n const app = entitledApps[index];\n if (app) {\n event.preventDefault();\n onAppSelect?.(app);\n }\n }\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [enableShortcuts, apps, onAppSelect, toggleSidebar]);\n\n // Đổi route → đóng mọi overlay đang mở.\n // biome-ignore lint/correctness/useExhaustiveDependencies: chủ đích chạy theo MỌI lần đổi pathname\n useEffect(() => {\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n setCommandPaletteOpen(false);\n }, [pathname]);\n\n const value = useMemo<ShellContextValue>(\n () => ({\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n nav: navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n setSidebarCollapsed: applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n setMobileMenuOpen,\n commandPaletteOpen,\n setCommandPaletteOpen,\n appLauncherOpen,\n setAppLauncherOpen,\n labels\n }),\n [\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n commandPaletteOpen,\n appLauncherOpen,\n labels\n ]\n );\n\n return (\n <ShellContext.Provider value={value}>\n <TooltipProvider delayDuration={200}>{children}</TooltipProvider>\n </ShellContext.Provider>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/shell-context.tsx"],"sourcesContent":["\"use client\";\n\n/*\n * ĐUÔI `.js` LÀ CHỦ ĐÍCH, ĐỪNG BỎ.\n *\n * Gói `next` KHÔNG khai `exports` map, nên `next/link` chỉ là đường dẫn file. Bundler\n * (webpack/Turbopack) tự thêm đuôi rồi trỏ đúng `node_modules/next/link.js`; Node ESM thuần thì\n * KHÔNG — nó ném `ERR_MODULE_NOT_FOUND: Cannot find module .../next/link` kèm gợi ý\n * \"Did you mean next/link.js?\". Hậu quả: `node -e \"await import('@comitor/ui/shell')\"` chết ngay\n * lúc nạp, tức là mọi smoke-test entry của CI đều mù với riêng entry shell.\n *\n * Ghi thẳng `next/link.js` / `next/navigation.js` là CÙNG MỘT file mà bundler vẫn resolve tới\n * (đã đối chiếu: `next` không có `resolve.alias` cho hai specifier này trong\n * `dist/build/create-compiler-aliases.js`), nhưng Node cũng nạp được.\n */\nimport NextLinkModule from \"next/link.js\";\nimport { usePathname } from \"next/navigation.js\";\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { TooltipProvider } from \"../components/ui/tooltip.js\";\nimport { layout } from \"../tokens.js\";\nimport {\n type AppDescriptor,\n DEFAULT_SHELL_LABELS,\n type NavGroup,\n type ShellLabels,\n type ShellLinkComponent,\n type ShellLinkProps,\n type ShellNav,\n type ShellUser,\n type Workspace\n} from \"./types.js\";\nimport { getEntitledApps, type NavSearchParams, resolveCurrentApp, toNavGroups, toSearchParams } from \"./utils.js\";\n\n/* ────────────────────────────────────────────────────────────────────────────\n Link mặc định\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Chuẩn hoá interop của `next/link.js`.\n *\n * Hai môi trường trả về hai hình dạng khác nhau cho cùng một file CJS:\n * - bundler → đã interop sẵn, biến này CHÍNH LÀ component `Link`\n * - Node ESM → biến này là namespace `{ default: Link, useLinkStatus }`\n * `.default ?? chính nó` đúng ở cả hai (component `Link` không có thuộc tính `default`).\n */\nconst NextLink = ((NextLinkModule as unknown as { default?: ShellLinkComponent }).default ??\n NextLinkModule) as ShellLinkComponent;\n\n/**\n * Bọc `next/link` thay vì truyền thẳng: hình dạng props của NextLink rộng hơn\n * `ShellLinkProps` (href nhận cả UrlObject), bọc lại cho khớp type và để chỗ\n * thay adapter router khác về sau.\n */\nfunction DefaultShellLink({ href, children, ...props }: ShellLinkProps) {\n return (\n <NextLink href={href} {...props}>\n {children}\n </NextLink>\n );\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Context\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellContextValue {\n /* Trục 1 — workspace */\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /* Trục 2 — app */\n apps: AppDescriptor[];\n currentApp: AppDescriptor | null;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /* Điều hướng trong app hiện tại */\n nav: NavGroup[];\n pathname: string;\n /**\n * Query hiện tại đã chuẩn hoá, hoặc `null` khi app không truyền.\n * `null` KHÁC rỗng: rỗng nghĩa là \"URL không có query\", `null` nghĩa là \"shell không biết\"\n * — và active matching xử lý hai ca đó khác nhau (xem `isNavItemActive`).\n */\n searchParams: URLSearchParams | null;\n LinkComponent: ShellLinkComponent;\n onNavigate?: (href: string) => void;\n\n /* Người dùng */\n user: ShellUser | null;\n onLogout?: () => void;\n\n /* Trạng thái khung */\n sidebarCollapsed: boolean;\n setSidebarCollapsed: (collapsed: boolean) => void;\n toggleSidebar: () => void;\n mobileMenuOpen: boolean;\n setMobileMenuOpen: (open: boolean) => void;\n commandPaletteOpen: boolean;\n setCommandPaletteOpen: (open: boolean) => void;\n appLauncherOpen: boolean;\n setAppLauncherOpen: (open: boolean) => void;\n\n /* Chuỗi hiển thị (đã trộn mặc định tiếng Việt với override của app) */\n labels: ShellLabels;\n}\n\nconst ShellContext = createContext<ShellContextValue | undefined>(undefined);\n\n/** Truy cập state của shell — phải gọi bên trong `<ShellProvider>`. */\nexport function useShell(): ShellContextValue {\n const context = useContext(ShellContext);\n if (!context) {\n throw new Error(\"useShell phải được dùng bên trong <ShellProvider> (hoặc <AppShell>).\");\n }\n return context;\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Ghi nhớ trạng thái thu gọn sidebar\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Khoá localStorage theo workspace — mỗi workspace nhớ riêng trạng thái sidebar\n * (spec §7.6: KHÔNG dùng cookie `sidebar_state` của shadcn để tránh hai nguồn sự thật).\n */\nfunction sidebarStorageKey(workspaceId: string | undefined): string {\n return `comitor-sidebar:${workspaceId ?? \"default\"}`;\n}\n\n/**\n * Đang ở dải TABLET? — từ `breakpoints.mobile` (768px, mốc thanh bên thôi off-canvas) tới ngay\n * dưới `breakpoints.tablet` (1024px).\n *\n * Hai mốc lấy từ `layout.breakpoints` của `@comitor/ui/tokens` chứ không gõ số ở đây: đổi mốc mà\n * quên chỗ này thì thanh bên tự sập ở một dải mà CSS không còn coi là tablet nữa.\n */\nfunction isTabletWidth(): boolean {\n if (typeof window === \"undefined\") return false;\n const { mobile, tablet } = layout.breakpoints;\n return window.matchMedia(`(min-width: ${mobile}px) and (max-width: ${tablet - 1}px)`).matches;\n}\n\nfunction readStoredCollapsed(workspaceId: string | undefined): boolean | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.localStorage.getItem(sidebarStorageKey(workspaceId));\n if (raw === \"1\") return true;\n if (raw === \"0\") return false;\n return null;\n } catch {\n // Safari private mode / storage bị chặn — coi như chưa có giá trị lưu.\n return null;\n }\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Provider\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellProviderProps {\n /** Trục 1 — danh sách workspace của user. Rỗng/1 phần tử thì WorkspaceSwitcher tự ẩn danh sách. */\n workspaces?: Workspace[];\n /** Workspace đang mở; bỏ trống → lấy phần tử đầu. */\n currentWorkspaceId?: string;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /** Trục 2 — danh sách app của hệ sinh thái (kể cả app chưa mở khoá). */\n apps?: AppDescriptor[];\n /** App đang mở; bỏ trống → suy từ `pathname` (khớp prefix dài nhất). */\n currentAppId?: string;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /** Menu của app hiện tại — `NavItem[]` hoặc `NavGroup[]`. */\n nav?: ShellNav;\n /**\n * Ghi đè pathname (Storybook, app không-Next). Bỏ trống → dùng `usePathname()`\n * của next/navigation (trả `null` khi không có App Router → coi như \"\").\n */\n pathname?: string;\n /**\n * Query hiện tại — CHỈ cần khi menu có mục phân biệt nhau bằng query\n * (`/cai-dat?tab=chung` ⇄ `/cai-dat?tab=thanh-vien`). Bỏ trống thì mọi mục cùng đường dẫn\n * cùng sáng.\n *\n * Nhận `useSearchParams()` của Next, chuỗi `\"?tab=x\"`, hoặc object `searchParams` của\n * Server Component.\n *\n * ⚠ Shell KHÔNG tự gọi `useSearchParams()`: hook đó ép trang bọc `<Suspense>` và đẩy trang\n * sang render phía client khi build tĩnh — cái giá đó phải do app tự chọn, không phải do\n * việc lỡ import shell. Truyền vào từ một client component đã có sẵn boundary:\n *\n * ```tsx\n * \"use client\";\n * const searchParams = useSearchParams();\n * return <AppShell searchParams={searchParams} …>{children}</AppShell>;\n * ```\n */\n searchParams?: NavSearchParams | null;\n /**\n * Ở dải TABLET (768–1023px), khi người dùng CHƯA từng gạt thanh bên, mở app lần đầu thì thanh\n * bên thu gọn sẵn. Mặc định `true`.\n *\n * Đặt `false` khi app muốn thanh bên luôn mở rộng ở tablet — vd điều hướng là thứ chính của\n * màn hình đó. Lựa chọn ĐÃ LƯU của người dùng luôn thắng cờ này.\n */\n collapseSidebarOnTablet?: boolean;\n /** Component link thay `next/link` (app không-Next). */\n LinkComponent?: ShellLinkComponent;\n /** Gọi sau mỗi lần người dùng bấm một mục điều hướng (analytics, đóng panel…). */\n onNavigate?: (href: string) => void;\n\n user?: ShellUser | null;\n onLogout?: () => void;\n\n /** Trạng thái thu gọn ban đầu (SSR). Sau khi mount, localStorage của workspace thắng. */\n defaultSidebarCollapsed?: boolean;\n /** Tắt ghi nhớ localStorage (Storybook, chế độ kiosk). */\n persistSidebarState?: boolean;\n /** Tắt bộ phím tắt toàn cục. */\n enableShortcuts?: boolean;\n\n /** Override chuỗi hiển thị (mặc định tiếng Việt). */\n labels?: Partial<ShellLabels>;\n\n children: ReactNode;\n}\n\n/**\n * Provider của app shell Comitor: giữ hai trục điều hướng (workspace × app),\n * trạng thái khung (sidebar/mobile/command palette/app launcher), phím tắt toàn cục,\n * và bọc sẵn `TooltipProvider`.\n *\n * KHÔNG dùng `useRouter()` của next/navigation: hook đó *ném lỗi* khi thiếu App Router\n * (Storybook, test). Mọi điều hướng chủ động là việc của app qua callback\n * `onWorkspaceChange` / `onAppSelect` / `onNavigate`; link tĩnh đi qua `LinkComponent`.\n */\nexport function ShellProvider({\n workspaces = [],\n currentWorkspaceId,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps = [],\n currentAppId,\n onAppSelect,\n onUpsell,\n nav,\n pathname: pathnameProp,\n searchParams: searchParamsProp,\n LinkComponent = DefaultShellLink,\n onNavigate,\n user = null,\n onLogout,\n defaultSidebarCollapsed = false,\n collapseSidebarOnTablet = true,\n persistSidebarState = true,\n enableShortcuts = true,\n labels: labelsProp,\n children\n}: ShellProviderProps) {\n // `usePathname()` trả null khi không có App Router → fallback chuỗi rỗng thay vì crash.\n const nextPathname = usePathname();\n const pathname = pathnameProp ?? nextPathname ?? \"\";\n\n // Chuẩn hoá MỘT lần ở đây thay vì mỗi lần `isNavItemActive` chạy: prop có thể là chuỗi\n // hoặc object, mà mỗi lần dựng `URLSearchParams` mới là một identity mới → mọi memo bên\n // dưới hỏng. Khoá memo là chính prop app truyền vào.\n const searchParams = useMemo(() => toSearchParams(searchParamsProp), [searchParamsProp]);\n\n const currentWorkspace = useMemo(\n () => workspaces.find((workspace) => workspace.id === currentWorkspaceId) ?? workspaces[0] ?? null,\n [workspaces, currentWorkspaceId]\n );\n\n const currentApp = useMemo(() => {\n if (currentAppId) return apps.find((app) => app.id === currentAppId) ?? null;\n return resolveCurrentApp(apps, pathname);\n }, [apps, currentAppId, pathname]);\n\n const navGroups = useMemo(() => toNavGroups(nav), [nav]);\n const labels = useMemo<ShellLabels>(() => ({ ...DEFAULT_SHELL_LABELS, ...labelsProp }), [labelsProp]);\n\n const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultSidebarCollapsed);\n const [mobileMenuOpen, setMobileMenuOpen] = useState(false);\n const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);\n const [appLauncherOpen, setAppLauncherOpen] = useState(false);\n\n /*\n * Nạp trạng thái đã lưu SAU khi mount (đọc localStorage lúc render đầu sẽ lệch hydration).\n *\n * Nếu CHƯA có lựa chọn nào của người dùng thì ở dải TABLET thanh bên mặc định thu gọn: từ 768px\n * thanh bên đã chiếm chỗ thật trong bố cục (dưới mức đó nó là sheet off-canvas), mà 768–1023px\n * thì 256px của nó ăn tới một phần ba bề ngang — vùng nội dung còn ~512px cho một bảng nhiều cột.\n *\n * ⚠ KHÔNG ghi lựa chọn này vào localStorage. Đây là GIÁ TRỊ MẶC ĐỊNH, không phải quyết định của\n * người dùng: ghi xuống là lần sau mở ở màn hình rộng thanh bên vẫn thu gọn vì \"đã có lựa chọn\n * lưu\", mà người dùng thì chưa bấm gì cả. Chỉ khi họ tự gạt thì `applySidebarCollapsed` mới lưu.\n *\n * ⚠ Chỉ chạy MỘT LẦN lúc mở, không theo dõi resize. \"Mặc định khi mới mở\" khác với \"ép theo bề\n * ngang\": kéo cửa sổ hẹp lại giữa phiên mà thanh bên tự sập là giật thứ người dùng đang cầm.\n *\n * Đọc bề ngang trong effect nên không đụng tới hydration — server không có `window`, và lần\n * render đầu ở client vẫn dùng đúng `defaultSidebarCollapsed` mà server đã render.\n */\n useEffect(() => {\n const stored = persistSidebarState ? readStoredCollapsed(currentWorkspace?.id) : null;\n if (stored !== null) {\n setSidebarCollapsed(stored);\n return;\n }\n if (collapseSidebarOnTablet && isTabletWidth()) setSidebarCollapsed(true);\n }, [persistSidebarState, currentWorkspace?.id, collapseSidebarOnTablet]);\n\n const persistCollapsed = useCallback(\n (collapsed: boolean) => {\n if (!persistSidebarState || typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(sidebarStorageKey(currentWorkspace?.id), collapsed ? \"1\" : \"0\");\n } catch {\n // Bỏ qua: không ghi được localStorage thì trạng thái chỉ sống trong phiên.\n }\n },\n [persistSidebarState, currentWorkspace?.id]\n );\n\n const applySidebarCollapsed = useCallback(\n (collapsed: boolean) => {\n setSidebarCollapsed(collapsed);\n persistCollapsed(collapsed);\n },\n [persistCollapsed]\n );\n\n const toggleSidebar = useCallback(() => {\n setSidebarCollapsed((prev) => {\n const next = !prev;\n persistCollapsed(next);\n return next;\n });\n }, [persistCollapsed]);\n\n // Phím tắt toàn cục (§7.4)\n useEffect(() => {\n if (!enableShortcuts) return;\n\n const entitledApps = getEntitledApps(apps);\n\n const handleKeyDown = (event: KeyboardEvent) => {\n const target = event.target;\n // Đang gõ trong ô nhập → nhường phím cho ô nhập.\n const isTyping =\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement ||\n (target instanceof HTMLElement && target.isContentEditable);\n\n if (event.key === \"Escape\") {\n setCommandPaletteOpen(false);\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n return;\n }\n\n if (isTyping) return;\n\n const mod = event.metaKey || event.ctrlKey;\n const key = event.key.toLowerCase();\n\n // Cmd/Ctrl + Shift + K — AppLauncher (phải xét TRƯỚC Cmd+K vì cùng phím \"k\")\n if (mod && event.shiftKey && key === \"k\") {\n event.preventDefault();\n setAppLauncherOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + K — Command Palette\n if (mod && !event.shiftKey && key === \"k\") {\n event.preventDefault();\n setCommandPaletteOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + B — thu gọn/mở sidebar\n if (mod && !event.shiftKey && key === \"b\") {\n event.preventDefault();\n toggleSidebar();\n return;\n }\n\n // Alt + 1…9 — nhảy tới app thứ n đã được cấp quyền.\n // Dùng `event.code` chứ không `event.key`: trên macOS Alt+1 cho ra \"¡\".\n if (event.altKey && !event.ctrlKey && !event.metaKey) {\n const match = /^Digit([1-9])$/.exec(event.code);\n if (match?.[1]) {\n const index = Number.parseInt(match[1], 10) - 1;\n const app = entitledApps[index];\n if (app) {\n event.preventDefault();\n onAppSelect?.(app);\n }\n }\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [enableShortcuts, apps, onAppSelect, toggleSidebar]);\n\n // Đổi route → đóng mọi overlay đang mở.\n // biome-ignore lint/correctness/useExhaustiveDependencies: chủ đích chạy theo MỌI lần đổi pathname\n useEffect(() => {\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n setCommandPaletteOpen(false);\n }, [pathname]);\n\n const value = useMemo<ShellContextValue>(\n () => ({\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n nav: navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n setSidebarCollapsed: applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n setMobileMenuOpen,\n commandPaletteOpen,\n setCommandPaletteOpen,\n appLauncherOpen,\n setAppLauncherOpen,\n labels\n }),\n [\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n commandPaletteOpen,\n appLauncherOpen,\n labels\n ]\n );\n\n return (\n <ShellContext.Provider value={value}>\n <TooltipProvider delayDuration={200}>{children}</TooltipProvider>\n </ShellContext.Provider>\n );\n}\n"],"mappings":";AAuDI;AAxCJ,OAAO,oBAAoB;AAC3B,SAAS,mBAAmB;AAC5B,SAAS,eAA+B,aAAa,YAAY,WAAW,SAAS,gBAAgB;AACrG,SAAS,uBAAuB;AAChC,SAAS,cAAc;AACvB;AAAA,EAEE;AAAA,OAQK;AACP,SAAS,iBAAuC,mBAAmB,aAAa,sBAAsB;AActG,MAAM,WAAa,eAA+D,WAChF;AAOF,SAAS,iBAAiB,EAAE,MAAM,UAAU,GAAG,MAAM,GAAmB;AACtE,SACE,oBAAC,YAAS,MAAa,GAAG,OACvB,UACH;AAEJ;AAoDA,MAAM,eAAe,cAA6C,MAAS;AAGpE,SAAS,WAA8B;AAC5C,QAAM,UAAU,WAAW,YAAY;AACvC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,qGAAsE;AAAA,EACxF;AACA,SAAO;AACT;AAUA,SAAS,kBAAkB,aAAyC;AAClE,SAAO,mBAAmB,eAAe,SAAS;AACpD;AASA,SAAS,gBAAyB;AAChC,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAM,EAAE,QAAQ,OAAO,IAAI,OAAO;AAClC,SAAO,OAAO,WAAW,eAAe,MAAM,uBAAuB,SAAS,CAAC,KAAK,EAAE;AACxF;AAEA,SAAS,oBAAoB,aAAiD;AAC5E,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,MAAI;AACF,UAAM,MAAM,OAAO,aAAa,QAAQ,kBAAkB,WAAW,CAAC;AACtE,QAAI,QAAQ,IAAK,QAAO;AACxB,QAAI,QAAQ,IAAK,QAAO;AACxB,WAAO;AAAA,EACT,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAuFO,SAAS,cAAc;AAAA,EAC5B,aAAa,CAAC;AAAA,EACd;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO,CAAC;AAAA,EACR;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,cAAc;AAAA,EACd,gBAAgB;AAAA,EAChB;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,0BAA0B;AAAA,EAC1B,0BAA0B;AAAA,EAC1B,sBAAsB;AAAA,EACtB,kBAAkB;AAAA,EAClB,QAAQ;AAAA,EACR;AACF,GAAuB;AAErB,QAAM,eAAe,YAAY;AACjC,QAAM,WAAW,gBAAgB,gBAAgB;AAKjD,QAAM,eAAe,QAAQ,MAAM,eAAe,gBAAgB,GAAG,CAAC,gBAAgB,CAAC;AAEvF,QAAM,mBAAmB;AAAA,IACvB,MAAM,WAAW,KAAK,CAAC,cAAc,UAAU,OAAO,kBAAkB,KAAK,WAAW,CAAC,KAAK;AAAA,IAC9F,CAAC,YAAY,kBAAkB;AAAA,EACjC;AAEA,QAAM,aAAa,QAAQ,MAAM;AAC/B,QAAI,aAAc,QAAO,KAAK,KAAK,CAAC,QAAQ,IAAI,OAAO,YAAY,KAAK;AACxE,WAAO,kBAAkB,MAAM,QAAQ;AAAA,EACzC,GAAG,CAAC,MAAM,cAAc,QAAQ,CAAC;AAEjC,QAAM,YAAY,QAAQ,MAAM,YAAY,GAAG,GAAG,CAAC,GAAG,CAAC;AACvD,QAAM,SAAS,QAAqB,OAAO,EAAE,GAAG,sBAAsB,GAAG,WAAW,IAAI,CAAC,UAAU,CAAC;AAEpG,QAAM,CAAC,kBAAkB,mBAAmB,IAAI,SAAS,uBAAuB;AAChF,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,SAAS,KAAK;AAC1D,QAAM,CAAC,oBAAoB,qBAAqB,IAAI,SAAS,KAAK;AAClE,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,SAAS,KAAK;AAmB5D,YAAU,MAAM;AACd,UAAM,SAAS,sBAAsB,oBAAoB,kBAAkB,EAAE,IAAI;AACjF,QAAI,WAAW,MAAM;AACnB,0BAAoB,MAAM;AAC1B;AAAA,IACF;AACA,QAAI,2BAA2B,cAAc,EAAG,qBAAoB,IAAI;AAAA,EAC1E,GAAG,CAAC,qBAAqB,kBAAkB,IAAI,uBAAuB,CAAC;AAEvE,QAAM,mBAAmB;AAAA,IACvB,CAAC,cAAuB;AACtB,UAAI,CAAC,uBAAuB,OAAO,WAAW,YAAa;AAC3D,UAAI;AACF,eAAO,aAAa,QAAQ,kBAAkB,kBAAkB,EAAE,GAAG,YAAY,MAAM,GAAG;AAAA,MAC5F,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,IACA,CAAC,qBAAqB,kBAAkB,EAAE;AAAA,EAC5C;AAEA,QAAM,wBAAwB;AAAA,IAC5B,CAAC,cAAuB;AACtB,0BAAoB,SAAS;AAC7B,uBAAiB,SAAS;AAAA,IAC5B;AAAA,IACA,CAAC,gBAAgB;AAAA,EACnB;AAEA,QAAM,gBAAgB,YAAY,MAAM;AACtC,wBAAoB,CAAC,SAAS;AAC5B,YAAM,OAAO,CAAC;AACd,uBAAiB,IAAI;AACrB,aAAO;AAAA,IACT,CAAC;AAAA,EACH,GAAG,CAAC,gBAAgB,CAAC;AAGrB,YAAU,MAAM;AACd,QAAI,CAAC,gBAAiB;AAEtB,UAAM,eAAe,gBAAgB,IAAI;AAEzC,UAAM,gBAAgB,CAAC,UAAyB;AAC9C,YAAM,SAAS,MAAM;AAErB,YAAM,WACJ,kBAAkB,oBAClB,kBAAkB,uBAClB,kBAAkB,qBACjB,kBAAkB,eAAe,OAAO;AAE3C,UAAI,MAAM,QAAQ,UAAU;AAC1B,8BAAsB,KAAK;AAC3B,0BAAkB,KAAK;AACvB,2BAAmB,KAAK;AACxB;AAAA,MACF;AAEA,UAAI,SAAU;AAEd,YAAM,MAAM,MAAM,WAAW,MAAM;AACnC,YAAM,MAAM,MAAM,IAAI,YAAY;AAGlC,UAAI,OAAO,MAAM,YAAY,QAAQ,KAAK;AACxC,cAAM,eAAe;AACrB,2BAAmB,CAAC,SAAS,CAAC,IAAI;AAClC;AAAA,MACF;AAGA,UAAI,OAAO,CAAC,MAAM,YAAY,QAAQ,KAAK;AACzC,cAAM,eAAe;AACrB,8BAAsB,CAAC,SAAS,CAAC,IAAI;AACrC;AAAA,MACF;AAGA,UAAI,OAAO,CAAC,MAAM,YAAY,QAAQ,KAAK;AACzC,cAAM,eAAe;AACrB,sBAAc;AACd;AAAA,MACF;AAIA,UAAI,MAAM,UAAU,CAAC,MAAM,WAAW,CAAC,MAAM,SAAS;AACpD,cAAM,QAAQ,iBAAiB,KAAK,MAAM,IAAI;AAC9C,YAAI,QAAQ,CAAC,GAAG;AACd,gBAAM,QAAQ,OAAO,SAAS,MAAM,CAAC,GAAG,EAAE,IAAI;AAC9C,gBAAM,MAAM,aAAa,KAAK;AAC9B,cAAI,KAAK;AACP,kBAAM,eAAe;AACrB,0BAAc,GAAG;AAAA,UACnB;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,WAAO,iBAAiB,WAAW,aAAa;AAChD,WAAO,MAAM,OAAO,oBAAoB,WAAW,aAAa;AAAA,EAClE,GAAG,CAAC,iBAAiB,MAAM,aAAa,aAAa,CAAC;AAItD,YAAU,MAAM;AACd,sBAAkB,KAAK;AACvB,uBAAmB,KAAK;AACxB,0BAAsB,KAAK;AAAA,EAC7B,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,QAAQ;AAAA,IACZ,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,KAAK;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,qBAAqB;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACA;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,SACE,oBAAC,aAAa,UAAb,EAAsB,OACrB,8BAAC,mBAAgB,eAAe,KAAM,UAAS,GACjD;AAEJ;","names":[]}
|
|
@@ -36,7 +36,7 @@ function SidebarMenuItem({
|
|
|
36
36
|
const disabled = Boolean(item.disabled);
|
|
37
37
|
const rowActive = active || branchActive && !hasChildren;
|
|
38
38
|
const rowClass = cn(
|
|
39
|
-
"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-
|
|
39
|
+
"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-(--nav-item-py) text-sm",
|
|
40
40
|
"transition-colors duration-150 ease-out",
|
|
41
41
|
depth > 0 && "pl-8 text-[13px]",
|
|
42
42
|
rowActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "text-sidebar-foreground/70",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/sidebar-menu-item.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../components/ui/collapsible.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { NavBadgeVariant, NavItem } from \"./types.js\";\nimport { isNavBranchActive, isNavItemActive } from \"./utils.js\";\n\n/** Variant badge của NavItem → variant của primitive Badge (tên trùng nhau, map tường minh cho an toàn). */\nconst BADGE_VARIANT: Record<NavBadgeVariant, \"default\" | \"destructive\" | \"warning\" | \"success\"> = {\n default: \"default\",\n destructive: \"destructive\",\n warning: \"warning\",\n success: \"success\"\n};\n\n/**\n * Vòng focus cho mục đã khoá.\n *\n * Mục khoá vẫn nằm trong tab order (`tabIndex={0}` + `aria-disabled`) theo đúng khuyến nghị\n * của ARIA APG: người dùng bàn phím phải *biết* mục đó tồn tại và đang bị khoá, thay vì mục\n * lặng lẽ biến mất. Không có href và không có handler nên Enter/click không đi đâu cả.\n */\nconst FOCUS_RING = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\";\n\nexport interface SidebarMenuItemProps {\n item: NavItem;\n /** Sidebar đang thu gọn (chỉ icon) — chỉ áp dụng cho mục cấp 0. */\n collapsed?: boolean;\n /** Cấp lồng (0 = gốc). Cấp > 0 thụt lề và không có icon vuông. */\n depth?: number;\n /**\n * Tập mục để đối chiếu khớp CHÍNH XÁC pathname — nên truyền toàn bộ mục của app\n * hiện tại. Thiếu nó, `/tasks` sẽ vẫn sáng khi đang ở `/tasks/inbox`.\n */\n siblings?: NavItem[];\n /** Gọi khi người dùng bấm vào một LINK thật (không gọi khi chỉ mở/đóng nhánh con) —\n * `MobileMenu` dùng để tự đóng. */\n onNavigate?: () => void;\n className?: string;\n}\n\n/**\n * Một mục menu sidebar: active matching thông minh, badge số, mục con `Collapsible`,\n * khi sidebar thu gọn thì hiện `Tooltip` + chấm badge.\n */\nexport function SidebarMenuItem({\n item,\n collapsed = false,\n depth = 0,\n siblings = [],\n onNavigate,\n className\n}: SidebarMenuItemProps) {\n const { pathname, searchParams } = useShell();\n\n const active = isNavItemActive(item, pathname, siblings, searchParams);\n const branchActive = isNavBranchActive(item, pathname, siblings, searchParams);\n const hasChildren = Boolean(item.children?.length);\n\n const [open, setOpen] = useState(branchActive);\n\n // Điều hướng tới một mục con → tự mở nhánh chứa nó.\n useEffect(() => {\n if (branchActive) setOpen(true);\n }, [branchActive]);\n\n const Icon = item.icon;\n const disabled = Boolean(item.disabled);\n const rowActive = active || (branchActive && !hasChildren);\n\n const rowClass = cn(\n \"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm\",\n \"transition-colors duration-150 ease-out\",\n depth > 0 && \"pl-8 text-[13px]\",\n rowActive ? \"bg-sidebar-accent font-medium text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n // Mục đã khoá thì KHÔNG gắn hover — đổi màu khi rê chuột là hứa hẹn bấm được.\n !disabled && !rowActive && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Mục khoá tô bằng MỘT bậc chữ mờ hơn, KHÔNG phải `opacity-50` chồng lên màu sẵn có.\n // Vì sao: mục khoá vẫn nằm trong tab order (`role=\"link\" aria-disabled tabIndex={0}` — xem\n // chú thích FOCUS_RING) nên KHÔNG được hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3,\n // mà `opacity-50` chồng lên `text-sidebar-foreground/70` cho độ mờ thực 35% → 2,28:1 (light)\n // / 3,10:1 (dark) ✗ cho chữ, và 27,5% trên icon → 1,86:1 / 2,39:1 ✗.\n // `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò này: 5,01:1 (light) / 5,35:1\n // (dark) trên `--sidebar` ✓. Tín hiệu \"khoá\" vẫn còn nguyên ở con trỏ và ở `aria-disabled`.\n disabled && \"cursor-not-allowed text-muted-foreground\",\n className\n );\n\n /**\n * Màu icon/vạch active dùng `--sidebar-accent-foreground`, KHÔNG dùng `--app-accent`.\n *\n * Accent mặc định (= `--primary` = gold #E8B824) chỉ đạt **1,70:1** trên nền hàng active\n * (`--sidebar-accent` #FBF5DC) ở light — dưới ngưỡng 3:1 của WCAG 1.4.11 cho thành phần\n * phi-văn-bản, nên vạch chỉ mục active gần như vô hình đúng lúc nó phải nói lên trạng thái.\n * (Ở dark thì gold #F5D060 trên #1E2128 đạt 10,8:1 — chỉ light hỏng.)\n * `--sidebar-accent-foreground` đạt 17,3:1 (light) và 15,1:1 (dark), lại là token semantic\n * nên app đổi theme vẫn đúng. Cùng cách xử lý mà `stat-card.tsx` đã chọn (`text-foreground`).\n *\n * Muốn vạch active mang màu riêng của app trở lại thì phải sửa ở tầng token: cho\n * `--app-accent` một bậc đủ tối ở light (vd gold-500 #7A5800 = 6,0:1), rồi mới quay lại\n * `bg-app-accent` ở đây.\n */\n const iconClass = cn(\n \"size-4 shrink-0\",\n active\n ? \"text-sidebar-accent-foreground\"\n : disabled\n ? \"text-muted-foreground\"\n : \"text-sidebar-foreground/55 group-hover:text-sidebar-foreground\"\n );\n\n /*\n * KHÔNG có vạch dọc bên trái ở hàng active.\n *\n * Bản duyệt có HAI cách vẽ mục active và chúng khác nhau: `SidebarNav` của trang tài liệu dùng\n * `border-l-2 border-gold-300`, còn khung ứng dụng ở mục **Patterns** chỉ tô nền\n * (`bg-gold-300/15 text-foreground`), không vạch. Shell này là bản dựng của Patterns nên theo\n * Patterns.\n *\n * Bỏ vạch KHÔNG làm mất tín hiệu trạng thái: hàng active vẫn khác hàng thường ở nền\n * (`--sidebar-accent`), ở màu chữ (`--sidebar-accent-foreground` thay cho\n * `--sidebar-foreground/70`), ở `font-medium`, và ở `aria-current=\"page\"` cho trình đọc màn\n * hình. WCAG 1.4.11 đòi tương phản cho thông tin thị giác, không đòi một hình dạng cụ thể.\n */\n\n const badge =\n item.badge === undefined || item.badge === null || item.badge === \"\" ? null : (\n <Badge variant={BADGE_VARIANT[item.badgeVariant ?? \"default\"]} size=\"sm\" className=\"ml-auto shrink-0\">\n {item.badge}\n </Badge>\n );\n\n /* ── Sidebar thu gọn: icon vuông + tooltip ─────────────────────────────── */\n if (collapsed && depth === 0) {\n const tileClass = cn(\n \"relative flex size-9 items-center justify-center rounded-md\",\n \"transition-colors duration-150 ease-out\",\n active ? \"bg-sidebar-accent text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n !disabled && !active && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Cùng lý do với `rowClass` ở trên: bậc chữ mờ hơn thay cho `opacity-50`.\n disabled && \"cursor-not-allowed text-muted-foreground\"\n );\n\n const tileBody = (\n <>\n {Icon ? <Icon className=\"size-4\" /> : null}\n {/* Thu gọn không đủ chỗ cho số → chỉ chấm báo có nội dung mới */}\n {item.badge ? <span className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive\" /> : null}\n </>\n );\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n {disabled ? (\n // Mục khoá: KHÔNG render <a> — xem chú thích ở nhánh mục lá bên dưới.\n <span\n role=\"link\"\n aria-disabled=\"true\"\n aria-label={item.label}\n tabIndex={0}\n className={cn(tileClass, FOCUS_RING)}\n >\n {tileBody}\n </span>\n ) : (\n <ShellNavLink\n href={item.href}\n external={item.external}\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n aria-label={item.label}\n className={tileClass}\n >\n {tileBody}\n </ShellNavLink>\n )}\n </TooltipTrigger>\n <TooltipContent side=\"right\" className=\"flex items-center gap-2\">\n <span>{item.label}</span>\n {item.badge ? <span className=\"text-xs opacity-70\">{item.badge}</span> : null}\n </TooltipContent>\n </Tooltip>\n );\n }\n\n /* ── Mục lá ────────────────────────────────────────────────────────────── */\n if (!hasChildren) {\n const leafBody = (\n <>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n </>\n );\n\n /*\n * Mục khoá phải dựng bằng <span role=\"link\" aria-disabled>, KHÔNG phải <a> tô mờ:\n * `pointer-events-none` chỉ chặn CHUỘT — người dùng bàn phím vẫn Tab tới thẻ <a> và\n * Enter vẫn điều hướng, lại không có tín hiệu nào cho trình đọc màn hình. Bỏ hẳn href\n * là cách duy nhất chặn được cả hai đường.\n */\n if (disabled) {\n return (\n <span role=\"link\" aria-disabled=\"true\" tabIndex={0} className={cn(rowClass, FOCUS_RING)}>\n {leafBody}\n </span>\n );\n }\n\n return (\n <ShellNavLink\n href={item.href}\n external={item.external}\n // Phải gọi `onNavigate` ở ĐÂY nữa, không chỉ ở nhánh thu gọn: `MobileMenu` truyền\n // `onNavigate={closeMenu}` cho mọi mục, thiếu nó thì bấm một mục điều hướng thường\n // trong menu mobile mà menu không đóng.\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n className={rowClass}\n >\n {leafBody}\n </ShellNavLink>\n );\n }\n\n /* ── Mục có con ────────────────────────────────────────────────────────── */\n return (\n <Collapsible open={open} onOpenChange={setOpen} disabled={disabled}>\n <CollapsibleTrigger asChild>\n {/* `disabled` thật trên <button>: bỏ khỏi tab order và chặn cả Enter/Space,\n khác hẳn `pointer-events-none` vốn chỉ chặn chuột. */}\n <button type=\"button\" disabled={disabled} className={rowClass} aria-expanded={open}>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n {/* `text-muted-foreground` chứ không `text-sidebar-foreground/40`: mũi tên MANG THÔNG TIN\n (nhánh đang đóng hay mở) nên chịu ngưỡng 3:1 của WCAG 1.4.11, mà /40 chỉ được\n 2,60:1 ở light ✗ (dark 3,64:1 — lại một chỗ mỗi theme hỏng một nửa).\n `--muted-foreground`: 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓ */}\n <ChevronRight\n className={cn(\n \"ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-150\",\n badge && \"ml-1\",\n open && \"rotate-90\"\n )}\n />\n </button>\n </CollapsibleTrigger>\n <CollapsibleContent className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\">\n <div className=\"mt-0.5 flex flex-col gap-0.5\">\n {item.children?.map((child) => (\n <SidebarMenuItem\n key={child.id}\n item={child}\n depth={depth + 1}\n siblings={siblings}\n onNavigate={onNavigate}\n />\n ))}\n </div>\n </CollapsibleContent>\n </Collapsible>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/sidebar-menu-item.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../components/ui/collapsible.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { NavBadgeVariant, NavItem } from \"./types.js\";\nimport { isNavBranchActive, isNavItemActive } from \"./utils.js\";\n\n/** Variant badge của NavItem → variant của primitive Badge (tên trùng nhau, map tường minh cho an toàn). */\nconst BADGE_VARIANT: Record<NavBadgeVariant, \"default\" | \"destructive\" | \"warning\" | \"success\"> = {\n default: \"default\",\n destructive: \"destructive\",\n warning: \"warning\",\n success: \"success\"\n};\n\n/**\n * Vòng focus cho mục đã khoá.\n *\n * Mục khoá vẫn nằm trong tab order (`tabIndex={0}` + `aria-disabled`) theo đúng khuyến nghị\n * của ARIA APG: người dùng bàn phím phải *biết* mục đó tồn tại và đang bị khoá, thay vì mục\n * lặng lẽ biến mất. Không có href và không có handler nên Enter/click không đi đâu cả.\n */\nconst FOCUS_RING = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\";\n\nexport interface SidebarMenuItemProps {\n item: NavItem;\n /** Sidebar đang thu gọn (chỉ icon) — chỉ áp dụng cho mục cấp 0. */\n collapsed?: boolean;\n /** Cấp lồng (0 = gốc). Cấp > 0 thụt lề và không có icon vuông. */\n depth?: number;\n /**\n * Tập mục để đối chiếu khớp CHÍNH XÁC pathname — nên truyền toàn bộ mục của app\n * hiện tại. Thiếu nó, `/tasks` sẽ vẫn sáng khi đang ở `/tasks/inbox`.\n */\n siblings?: NavItem[];\n /** Gọi khi người dùng bấm vào một LINK thật (không gọi khi chỉ mở/đóng nhánh con) —\n * `MobileMenu` dùng để tự đóng. */\n onNavigate?: () => void;\n className?: string;\n}\n\n/**\n * Một mục menu sidebar: active matching thông minh, badge số, mục con `Collapsible`,\n * khi sidebar thu gọn thì hiện `Tooltip` + chấm badge.\n */\nexport function SidebarMenuItem({\n item,\n collapsed = false,\n depth = 0,\n siblings = [],\n onNavigate,\n className\n}: SidebarMenuItemProps) {\n const { pathname, searchParams } = useShell();\n\n const active = isNavItemActive(item, pathname, siblings, searchParams);\n const branchActive = isNavBranchActive(item, pathname, siblings, searchParams);\n const hasChildren = Boolean(item.children?.length);\n\n const [open, setOpen] = useState(branchActive);\n\n // Điều hướng tới một mục con → tự mở nhánh chứa nó.\n useEffect(() => {\n if (branchActive) setOpen(true);\n }, [branchActive]);\n\n const Icon = item.icon;\n const disabled = Boolean(item.disabled);\n const rowActive = active || (branchActive && !hasChildren);\n\n const rowClass = cn(\n \"group relative flex w-full items-center gap-2.5 rounded-md px-2 py-(--nav-item-py) text-sm\",\n \"transition-colors duration-150 ease-out\",\n depth > 0 && \"pl-8 text-[13px]\",\n rowActive ? \"bg-sidebar-accent font-medium text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n // Mục đã khoá thì KHÔNG gắn hover — đổi màu khi rê chuột là hứa hẹn bấm được.\n !disabled && !rowActive && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Mục khoá tô bằng MỘT bậc chữ mờ hơn, KHÔNG phải `opacity-50` chồng lên màu sẵn có.\n // Vì sao: mục khoá vẫn nằm trong tab order (`role=\"link\" aria-disabled tabIndex={0}` — xem\n // chú thích FOCUS_RING) nên KHÔNG được hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3,\n // mà `opacity-50` chồng lên `text-sidebar-foreground/70` cho độ mờ thực 35% → 2,28:1 (light)\n // / 3,10:1 (dark) ✗ cho chữ, và 27,5% trên icon → 1,86:1 / 2,39:1 ✗.\n // `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò này: 5,01:1 (light) / 5,35:1\n // (dark) trên `--sidebar` ✓. Tín hiệu \"khoá\" vẫn còn nguyên ở con trỏ và ở `aria-disabled`.\n disabled && \"cursor-not-allowed text-muted-foreground\",\n className\n );\n\n /**\n * Màu icon/vạch active dùng `--sidebar-accent-foreground`, KHÔNG dùng `--app-accent`.\n *\n * Accent mặc định (= `--primary` = gold #E8B824) chỉ đạt **1,70:1** trên nền hàng active\n * (`--sidebar-accent` #FBF5DC) ở light — dưới ngưỡng 3:1 của WCAG 1.4.11 cho thành phần\n * phi-văn-bản, nên vạch chỉ mục active gần như vô hình đúng lúc nó phải nói lên trạng thái.\n * (Ở dark thì gold #F5D060 trên #1E2128 đạt 10,8:1 — chỉ light hỏng.)\n * `--sidebar-accent-foreground` đạt 17,3:1 (light) và 15,1:1 (dark), lại là token semantic\n * nên app đổi theme vẫn đúng. Cùng cách xử lý mà `stat-card.tsx` đã chọn (`text-foreground`).\n *\n * Muốn vạch active mang màu riêng của app trở lại thì phải sửa ở tầng token: cho\n * `--app-accent` một bậc đủ tối ở light (vd gold-500 #7A5800 = 6,0:1), rồi mới quay lại\n * `bg-app-accent` ở đây.\n */\n const iconClass = cn(\n \"size-4 shrink-0\",\n active\n ? \"text-sidebar-accent-foreground\"\n : disabled\n ? \"text-muted-foreground\"\n : \"text-sidebar-foreground/55 group-hover:text-sidebar-foreground\"\n );\n\n /*\n * KHÔNG có vạch dọc bên trái ở hàng active.\n *\n * Bản duyệt có HAI cách vẽ mục active và chúng khác nhau: `SidebarNav` của trang tài liệu dùng\n * `border-l-2 border-gold-300`, còn khung ứng dụng ở mục **Patterns** chỉ tô nền\n * (`bg-gold-300/15 text-foreground`), không vạch. Shell này là bản dựng của Patterns nên theo\n * Patterns.\n *\n * Bỏ vạch KHÔNG làm mất tín hiệu trạng thái: hàng active vẫn khác hàng thường ở nền\n * (`--sidebar-accent`), ở màu chữ (`--sidebar-accent-foreground` thay cho\n * `--sidebar-foreground/70`), ở `font-medium`, và ở `aria-current=\"page\"` cho trình đọc màn\n * hình. WCAG 1.4.11 đòi tương phản cho thông tin thị giác, không đòi một hình dạng cụ thể.\n */\n\n const badge =\n item.badge === undefined || item.badge === null || item.badge === \"\" ? null : (\n <Badge variant={BADGE_VARIANT[item.badgeVariant ?? \"default\"]} size=\"sm\" className=\"ml-auto shrink-0\">\n {item.badge}\n </Badge>\n );\n\n /* ── Sidebar thu gọn: icon vuông + tooltip ─────────────────────────────── */\n if (collapsed && depth === 0) {\n const tileClass = cn(\n \"relative flex size-9 items-center justify-center rounded-md\",\n \"transition-colors duration-150 ease-out\",\n active ? \"bg-sidebar-accent text-sidebar-accent-foreground\" : \"text-sidebar-foreground/70\",\n !disabled && !active && \"hover:bg-sidebar-accent/60 hover:text-sidebar-foreground\",\n // Cùng lý do với `rowClass` ở trên: bậc chữ mờ hơn thay cho `opacity-50`.\n disabled && \"cursor-not-allowed text-muted-foreground\"\n );\n\n const tileBody = (\n <>\n {Icon ? <Icon className=\"size-4\" /> : null}\n {/* Thu gọn không đủ chỗ cho số → chỉ chấm báo có nội dung mới */}\n {item.badge ? <span className=\"absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive\" /> : null}\n </>\n );\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n {disabled ? (\n // Mục khoá: KHÔNG render <a> — xem chú thích ở nhánh mục lá bên dưới.\n <span\n role=\"link\"\n aria-disabled=\"true\"\n aria-label={item.label}\n tabIndex={0}\n className={cn(tileClass, FOCUS_RING)}\n >\n {tileBody}\n </span>\n ) : (\n <ShellNavLink\n href={item.href}\n external={item.external}\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n aria-label={item.label}\n className={tileClass}\n >\n {tileBody}\n </ShellNavLink>\n )}\n </TooltipTrigger>\n <TooltipContent side=\"right\" className=\"flex items-center gap-2\">\n <span>{item.label}</span>\n {item.badge ? <span className=\"text-xs opacity-70\">{item.badge}</span> : null}\n </TooltipContent>\n </Tooltip>\n );\n }\n\n /* ── Mục lá ────────────────────────────────────────────────────────────── */\n if (!hasChildren) {\n const leafBody = (\n <>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n </>\n );\n\n /*\n * Mục khoá phải dựng bằng <span role=\"link\" aria-disabled>, KHÔNG phải <a> tô mờ:\n * `pointer-events-none` chỉ chặn CHUỘT — người dùng bàn phím vẫn Tab tới thẻ <a> và\n * Enter vẫn điều hướng, lại không có tín hiệu nào cho trình đọc màn hình. Bỏ hẳn href\n * là cách duy nhất chặn được cả hai đường.\n */\n if (disabled) {\n return (\n <span role=\"link\" aria-disabled=\"true\" tabIndex={0} className={cn(rowClass, FOCUS_RING)}>\n {leafBody}\n </span>\n );\n }\n\n return (\n <ShellNavLink\n href={item.href}\n external={item.external}\n // Phải gọi `onNavigate` ở ĐÂY nữa, không chỉ ở nhánh thu gọn: `MobileMenu` truyền\n // `onNavigate={closeMenu}` cho mọi mục, thiếu nó thì bấm một mục điều hướng thường\n // trong menu mobile mà menu không đóng.\n onClick={() => onNavigate?.()}\n aria-current={active ? \"page\" : undefined}\n className={rowClass}\n >\n {leafBody}\n </ShellNavLink>\n );\n }\n\n /* ── Mục có con ────────────────────────────────────────────────────────── */\n return (\n <Collapsible open={open} onOpenChange={setOpen} disabled={disabled}>\n <CollapsibleTrigger asChild>\n {/* `disabled` thật trên <button>: bỏ khỏi tab order và chặn cả Enter/Space,\n khác hẳn `pointer-events-none` vốn chỉ chặn chuột. */}\n <button type=\"button\" disabled={disabled} className={rowClass} aria-expanded={open}>\n {Icon ? <Icon className={iconClass} /> : null}\n <span className=\"truncate\">{item.label}</span>\n {badge}\n {/* `text-muted-foreground` chứ không `text-sidebar-foreground/40`: mũi tên MANG THÔNG TIN\n (nhánh đang đóng hay mở) nên chịu ngưỡng 3:1 của WCAG 1.4.11, mà /40 chỉ được\n 2,60:1 ở light ✗ (dark 3,64:1 — lại một chỗ mỗi theme hỏng một nửa).\n `--muted-foreground`: 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓ */}\n <ChevronRight\n className={cn(\n \"ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform duration-150\",\n badge && \"ml-1\",\n open && \"rotate-90\"\n )}\n />\n </button>\n </CollapsibleTrigger>\n <CollapsibleContent className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\">\n <div className=\"mt-0.5 flex flex-col gap-0.5\">\n {item.children?.map((child) => (\n <SidebarMenuItem\n key={child.id}\n item={child}\n depth={depth + 1}\n siblings={siblings}\n onNavigate={onNavigate}\n />\n ))}\n </div>\n </CollapsibleContent>\n </Collapsible>\n );\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
package/styles.css
CHANGED
|
@@ -450,6 +450,20 @@
|
|
|
450
450
|
--sidebar-width-icon: 3.5rem; /* 56px, sidebar thu gọn */
|
|
451
451
|
--header-height: 3.5rem; /* 56px */
|
|
452
452
|
|
|
453
|
+
/* ─ Thang MẬT ĐỘ — giá trị lúc THOẢI MÁI (mặc định) ─
|
|
454
|
+
Bốn token này là toàn bộ đường bật của "bố cục gọn"; khối 5d ghi đè chúng và không đụng gì
|
|
455
|
+
khác. Chỉ trục DỌC: gọn nghĩa là thấy nhiều dòng hơn, không phải chữ dính vào mép.
|
|
456
|
+
|
|
457
|
+
⚠ PHẢI khai ở đây kể cả khi giá trị bằng đúng literal cũ. Cổng tương phản chỉ đọc bốn khối
|
|
458
|
+
(:root · .dark · hai khối tương phản cao); token nào vắng mặt ở :root thì với cổng nó là
|
|
459
|
+
chuỗi rỗng, `parseLength` trả null và `measureShape` mất khả năng phân loại hình — hỏng ÂM
|
|
460
|
+
THẦM chứ không đỏ. */
|
|
461
|
+
--row-py: 0.75rem; /* 12px — py-3 của ô bảng */
|
|
462
|
+
--menu-item-py: 0.375rem; /* 6px — py-1.5 của mục menu */
|
|
463
|
+
--list-item-py: 0.75rem; /* 12px — py-3 của mục danh sách */
|
|
464
|
+
--nav-item-py: 0.375rem; /* 6px — py-1.5 của mục thanh bên */
|
|
465
|
+
--table-head-h: 2.5rem; /* 40px — h-10 của hàng tiêu đề bảng */
|
|
466
|
+
|
|
453
467
|
/* ─ Thang ĐỔ BÓNG (elevation) ─
|
|
454
468
|
Giá trị THÔ nằm ở đây chứ không nằm trong @theme, vì bóng phải đổi theo light/dark:
|
|
455
469
|
@theme thường "đóng băng" giá trị lúc build, chỉ biến CSS thật mới được khối .dark
|
|
@@ -469,7 +483,12 @@
|
|
|
469
483
|
--elevation-card: 0 1px 2px rgba(14, 17, 22, 0.04), 0 8px 24px rgba(14, 17, 22, 0.06);
|
|
470
484
|
--elevation-overlay: 0 8px 32px rgba(14, 17, 22, 0.16);
|
|
471
485
|
|
|
472
|
-
|
|
486
|
+
/* 6px — chữ ký thị giác của Comitor, docs/spec-package.md §2 quy tắc 2 khoá cứng.
|
|
487
|
+
Viết bằng px chứ KHÔNG phải 0.375rem, và đó là điều kiện để trục CỠ CHỮ (khối 5c) đúng: cỡ
|
|
488
|
+
chữ phóng theo tỉ lệ nên mọi giá trị tính bằng rem đi theo — bo góc mà đi theo thì ở mức
|
|
489
|
+
`lg` nó thành 6,75px, tức chữ ký thị giác đổi theo tuỳ chọn của người dùng. Ở mức mặc định
|
|
490
|
+
hai cách cho ra cùng một con số nên đây không phải thay đổi hình thức. */
|
|
491
|
+
--radius: 6px;
|
|
473
492
|
}
|
|
474
493
|
|
|
475
494
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
@@ -667,6 +686,68 @@
|
|
|
667
686
|
--yellow: #facc15;
|
|
668
687
|
}
|
|
669
688
|
|
|
689
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
690
|
+
5c. TRỤC THỨ TƯ — CỠ CHỮ `<html data-font-size="sm|lg">`
|
|
691
|
+
|
|
692
|
+
Đây là PHÓNG THEO TỈ LỆ, không phải "chỉ đổi cỡ chữ" — và đó là chủ ý.
|
|
693
|
+
|
|
694
|
+
Icon trong gói lấy cỡ từ thang KHOẢNG CÁCH chứ không phải thang chữ: 128 chỗ `size-3/4/5`
|
|
695
|
+
cộng 21 chỗ tự-động-cỡ `[&_svg:not([class*='size-'])]:size-4`, tất cả compile ra
|
|
696
|
+
`calc(var(--spacing) * n)`. Chiều cao control cũng vậy (`h-9`, `h-10`). Nếu chỉ ghi đè
|
|
697
|
+
`--text-*` thì ở mức `lg`: chữ 16px nằm cạnh icon vẫn 16px trong mọi hàng menu, nhãn
|
|
698
|
+
checkbox to ra trong khi ô `size-4` đứng yên, và `Kbd` (hộp 20px chứa chữ 12px) vỡ trước
|
|
699
|
+
tiên. Đổi `font-size` ở gốc thì mọi thứ tính bằng rem đi theo cùng nhau, tỉ lệ icon⇄chữ giữ
|
|
700
|
+
nguyên, và không phải sửa ~149 chỗ icon.
|
|
701
|
+
|
|
702
|
+
⚠ PHẢI LÀ PHẦN TRĂM, KHÔNG ĐƯỢC LÀ px. Người dùng có thể đã tăng cỡ chữ mặc định của trình
|
|
703
|
+
duyệt; khai `font-size: 16px` ở <html> là XOÁ lựa chọn đó — tự tay vi phạm WCAG 1.4.4 ngay
|
|
704
|
+
bên trong một tính năng trợ năng. Phần trăm là phần trăm CỦA CHÍNH cỡ đó nên vẫn tôn trọng.
|
|
705
|
+
|
|
706
|
+
⚠ Mức `md` KHÔNG có khối nào. Provider gỡ hẳn thuộc tính thay vì ghi `data-font-size="md"`,
|
|
707
|
+
nên ở mức mặc định không có một dòng CSS nào can thiệp vào cỡ gốc của trình duyệt.
|
|
708
|
+
|
|
709
|
+
Vì sao KHÔNG cần khai `--text-*` trong @theme: đã biên dịch thử bằng đúng tailwindcss 4.3.3
|
|
710
|
+
của repo — `text-sm` sinh ra `font-size: var(--text-sm); line-height: var(--tw-leading,
|
|
711
|
+
var(--text-sm--line-height))` dù gói có khai token hay không, và mặc định nằm trong
|
|
712
|
+
`@layer theme`. Ta không đụng tới chúng; ta đổi cỡ GỐC mà chúng quy chiếu.
|
|
713
|
+
──────────────────────────────────────────────────────────────────────────── */
|
|
714
|
+
[data-font-size="sm"] {
|
|
715
|
+
font-size: 87.5%;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
[data-font-size="lg"] {
|
|
719
|
+
font-size: 112.5%;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/* ────────────────────────────────────────────────────────────────────────────
|
|
723
|
+
5d. TRỤC THỨ BA — MẬT ĐỘ `<html data-density="compact">`
|
|
724
|
+
|
|
725
|
+
Bố cục gọn KHÔNG ghi đè `--spacing` toàn cục. Đã cân nhắc và loại: một dòng là xong, nhưng
|
|
726
|
+
nó co CẢ chiều ngang lẫn icon — `size-4` tụt còn ~12,8px, padding trái/phải hụt theo, và cả
|
|
727
|
+
quãng trượt của tw-animate cũng ngắn lại. Gọn về mã, xấu về kết quả.
|
|
728
|
+
|
|
729
|
+
Thay vào đó là một nhóm token nhắm đúng những bề mặt người dùng thấy CHẬT: hàng bảng, mục
|
|
730
|
+
menu, mục danh sách, mục thanh bên. Chỉ trục DỌC — chiều ngang giữ nguyên, vì "gọn" nghĩa là
|
|
731
|
+
thấy nhiều dòng hơn chứ không phải chữ dính vào mép.
|
|
732
|
+
|
|
733
|
+
⚠ Mỗi token PHẢI có giá trị mặc định ở `:root` (khối 4). Cổng tương phản chỉ đọc bốn khối
|
|
734
|
+
`:root` · `.dark` · `[data-contrast="high"]` · `.dark[data-contrast="high"]`; token nào chỉ
|
|
735
|
+
khai trong khối này thì với cổng nó là chuỗi rỗng — `parseLength` trả null, `measureShape`
|
|
736
|
+
mất `isBar`/`isBox`/`isTinyAxis`, và nền theo token BỀ MẶT thôi không còn chịu ngưỡng 3:1.
|
|
737
|
+
Hỏng ÂM THẦM, không đỏ. (Với token MÀU thì lỗi này nổ ra thành phát hiện, với token ĐỘ DÀI
|
|
738
|
+
thì không.)
|
|
739
|
+
|
|
740
|
+
Đây cũng là khoản mà docs/spec-package.md §2 quy tắc 1 đã đòi từ đầu ("mọi khoảng cách là CSS
|
|
741
|
+
variable"), nên nó là TRẢ NỢ chứ không phải thêm ý tưởng mới.
|
|
742
|
+
──────────────────────────────────────────────────────────────────────────── */
|
|
743
|
+
[data-density="compact"] {
|
|
744
|
+
--row-py: 0.375rem;
|
|
745
|
+
--menu-item-py: 0.25rem;
|
|
746
|
+
--list-item-py: 0.5rem;
|
|
747
|
+
--nav-item-py: 0.25rem;
|
|
748
|
+
--table-head-h: 2rem;
|
|
749
|
+
}
|
|
750
|
+
|
|
670
751
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
671
752
|
6. MAP BIẾN → NAMESPACE TAILWIND (sinh bg-*, text-*, border-*, rounded-*…)
|
|
672
753
|
|