@comitor/ui 0.2.0 → 0.2.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.
@@ -19,17 +19,59 @@ function flattenNavItems(groups) {
19
19
  for (const group of groups) walk(group.items);
20
20
  return out;
21
21
  }
22
- function isNavItemActive(item, pathname, siblings = []) {
22
+ function splitHref(href) {
23
+ const hashAt = href.indexOf("#");
24
+ const noHash = hashAt === -1 ? href : href.slice(0, hashAt);
25
+ const queryAt = noHash.indexOf("?");
26
+ if (queryAt === -1) return { path: noHash, query: "" };
27
+ return { path: noHash.slice(0, queryAt), query: noHash.slice(queryAt + 1) };
28
+ }
29
+ function toSearchParams(input) {
30
+ if (input === null || input === void 0) return null;
31
+ if (typeof input === "string") return new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);
32
+ if (typeof input.getAll === "function") return input;
33
+ const out = new URLSearchParams();
34
+ for (const [key, value] of Object.entries(input)) {
35
+ if (value === void 0) continue;
36
+ if (Array.isArray(value)) for (const one of value) out.append(key, one);
37
+ else out.append(key, value);
38
+ }
39
+ return out;
40
+ }
41
+ function queryIsSubsetOf(query, current) {
42
+ for (const [key, value] of new URLSearchParams(query)) {
43
+ if (!current.getAll(key).includes(value)) return false;
44
+ }
45
+ return true;
46
+ }
47
+ function navItemPath(href, pathname) {
48
+ const { path } = splitHref(href);
49
+ return path === "" ? pathname : path;
50
+ }
51
+ function isNavItemActive(item, pathname, siblings = [], searchParams) {
23
52
  if (item.isActive !== void 0) return item.isActive;
24
53
  if (!pathname || !item.href) return false;
25
- if (pathname === item.href) return true;
26
- const hasExactMatchElsewhere = siblings.some((other) => other.href !== item.href && pathname === other.href);
54
+ const { query } = splitHref(item.href);
55
+ const itemPath = navItemPath(item.href, pathname);
56
+ const current = toSearchParams(searchParams);
57
+ if (pathname === itemPath) {
58
+ if (!current) return true;
59
+ if (query !== "") return queryIsSubsetOf(query, current);
60
+ return !siblings.some((other) => {
61
+ if (other.href === item.href) return false;
62
+ const otherQuery = splitHref(other.href).query;
63
+ return otherQuery !== "" && navItemPath(other.href, pathname) === pathname && queryIsSubsetOf(otherQuery, current);
64
+ });
65
+ }
66
+ const hasExactMatchElsewhere = siblings.some(
67
+ (other) => other.href !== item.href && splitHref(other.href).path === pathname
68
+ );
27
69
  if (hasExactMatchElsewhere) return false;
28
- return item.href !== "/" && pathname.startsWith(`${item.href}/`);
70
+ return itemPath !== "/" && pathname.startsWith(`${itemPath}/`);
29
71
  }
30
- function isNavBranchActive(item, pathname, siblings = []) {
31
- if (isNavItemActive(item, pathname, siblings)) return true;
32
- return (item.children ?? []).some((child) => isNavBranchActive(child, pathname, siblings));
72
+ function isNavBranchActive(item, pathname, siblings = [], searchParams) {
73
+ if (isNavItemActive(item, pathname, siblings, searchParams)) return true;
74
+ return (item.children ?? []).some((child) => isNavBranchActive(child, pathname, siblings, searchParams));
33
75
  }
34
76
  function getEntitledApps(apps) {
35
77
  return apps.filter((app) => app.entitled !== false);
@@ -39,7 +81,7 @@ function getLockedApps(apps) {
39
81
  }
40
82
  function resolveCurrentApp(apps, pathname) {
41
83
  if (apps.length === 0) return null;
42
- const matched = [...apps].sort((a, b) => b.href.length - a.href.length).find((app) => pathname === app.href || app.href !== "/" && pathname.startsWith(`${app.href}/`));
84
+ const matched = apps.map((app) => ({ app, path: splitHref(app.href).path })).sort((a, b) => b.path.length - a.path.length).find(({ path }) => pathname === path || path !== "/" && path !== "" && pathname.startsWith(`${path}/`))?.app;
43
85
  return matched ?? apps[0] ?? null;
44
86
  }
45
87
  function appAccentStyle(app) {
@@ -60,6 +102,7 @@ export {
60
102
  isNavItemActive,
61
103
  normalizeVi,
62
104
  resolveCurrentApp,
63
- toNavGroups
105
+ toNavGroups,
106
+ toSearchParams
64
107
  };
65
108
  //# sourceMappingURL=utils.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/utils.ts"],"sourcesContent":["/**\n * Hàm thuần dùng chung cho tầng shell — KHÔNG hook, KHÔNG directive\n * (dùng được ở cả server lẫn client).\n */\n\nimport type { CSSProperties } from \"react\";\nimport type { AppDescriptor, NavGroup, NavItem, ShellNav } from \"./types.js\";\n\n/**\n * Tiện ích chuỗi tiếng Việt — **cùng một binding** với entry chính (`@comitor/ui`).\n *\n * Re-export chứ không viết lại. Hai lần repo này đã trả giá cho việc khai lại:\n * `getVietnameseInitials` từng có hai thân hàm (`toUpperCase()` ở đây,\n * `toLocaleUpperCase(\"vi-VN\")` ở Tier 2) nên cùng một tên cho ra hai kết quả tuỳ chỗ import;\n * `normalizeVi` thì có hai khai báo giống nhau chữ-cho-chữ, chưa sai kết quả nhưng là hai chỗ\n * để sửa lệch nhau lần sau. Tên vẫn xuất ở cả hai entry (không phá API của tầng shell), nhưng\n * chỉ còn MỘT thân hàm — xem docs/quyet-dinh-tich-hop.md §28 (\"một tên, một cửa\").\n */\nexport { getVietnameseInitials, normalizeVi } from \"../components/composite/text-utils.js\";\n\n/* ────────────────────────────────────────────────────────────────────────────\n Điều hướng\n ──────────────────────────────────────────────────────────────────────────── */\n\n/** Phân biệt `NavGroup` với `NavItem` khi app truyền mảng phẳng. */\nfunction isNavGroup(value: NavItem | NavGroup): value is NavGroup {\n return Array.isArray((value as NavGroup).items);\n}\n\n/**\n * Chuẩn hoá menu app truyền vào về `NavGroup[]`.\n * Mảng phẳng `NavItem[]` → một nhóm không nhãn (id \"default\").\n */\nexport function toNavGroups(nav: ShellNav | undefined): NavGroup[] {\n if (!nav || nav.length === 0) return [];\n const first = nav[0];\n if (first && isNavGroup(first)) return nav as NavGroup[];\n return [{ id: \"default\", items: nav as NavItem[] }];\n}\n\n/** Gộp mọi item của mọi nhóm thành một mảng phẳng (kể cả mục con). */\nexport function flattenNavItems(groups: NavGroup[]): NavItem[] {\n const out: NavItem[] = [];\n const walk = (items: NavItem[]) => {\n for (const item of items) {\n out.push(item);\n if (item.children?.length) walk(item.children);\n }\n };\n for (const group of groups) walk(group.items);\n return out;\n}\n\n/**\n * Active matching \"thông minh\": chỉ dùng `startsWith` khi KHÔNG có mục nào khác\n * khớp CHÍNH XÁC `pathname`. Nếu không, `/tasks` vẫn sáng khi đang đứng ở\n * `/tasks/inbox` (có mục riêng cho nó).\n *\n * @param siblings Danh sách mục để đối chiếu khớp chính xác — nên truyền toàn bộ\n * mục của app hiện tại (`flattenNavItems`), không chỉ mục cùng cấp.\n */\nexport function isNavItemActive(item: NavItem, pathname: string, siblings: NavItem[] = []): boolean {\n if (item.isActive !== undefined) return item.isActive;\n if (!pathname || !item.href) return false;\n if (pathname === item.href) return true;\n const hasExactMatchElsewhere = siblings.some((other) => other.href !== item.href && pathname === other.href);\n if (hasExactMatchElsewhere) return false;\n return item.href !== \"/\" && pathname.startsWith(`${item.href}/`);\n}\n\n/** Mục cha được coi là active khi chính nó hoặc bất kỳ mục con nào active. */\nexport function isNavBranchActive(item: NavItem, pathname: string, siblings: NavItem[] = []): boolean {\n if (isNavItemActive(item, pathname, siblings)) return true;\n return (item.children ?? []).some((child) => isNavBranchActive(child, pathname, siblings));\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n App (trục 2)\n ──────────────────────────────────────────────────────────────────────────── */\n\n/** App đã được cấp quyền (`entitled` khác `false`) — thứ tự giữ nguyên như app truyền vào. */\nexport function getEntitledApps(apps: AppDescriptor[]): AppDescriptor[] {\n return apps.filter((app) => app.entitled !== false);\n}\n\n/** App chưa mở khoá — hiển thị ở nhóm \"Khám phá thêm\" của AppLauncher. */\nexport function getLockedApps(apps: AppDescriptor[]): AppDescriptor[] {\n return apps.filter((app) => app.entitled === false);\n}\n\n/**\n * Suy ra app hiện tại từ `pathname` — khớp prefix DÀI NHẤT trước để\n * \"/crm/deals\" không rơi nhầm vào app có href \"/\".\n */\nexport function resolveCurrentApp(apps: AppDescriptor[], pathname: string): AppDescriptor | null {\n if (apps.length === 0) return null;\n const matched = [...apps]\n .sort((a, b) => b.href.length - a.href.length)\n .find((app) => pathname === app.href || (app.href !== \"/\" && pathname.startsWith(`${app.href}/`)));\n return matched ?? apps[0] ?? null;\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Biến CSS theo app\n\n (Màu avatar KHÔNG còn cửa riêng ở tầng shell: `getAvatarToneClass` — alias một dòng của\n `getAvatarToneClasses`, lệch tên tham số `seed` ⇄ `name` và phải kèm chú thích dặn \"seed\n phải là tên hiển thị\" — đã bị xoá ở 0.2.0. `UserMenu`/`WorkspaceSwitcher` nhập thẳng\n `getAvatarToneClasses` từ `composite/letter-avatar.js`; app dùng `@comitor/ui`.)\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Inline style gán bộ biến accent cho một app cụ thể.\n * Trả về `undefined` khi app không khai gì → giữ nguyên token mặc định của theme.\n *\n * Cả BA vai trò đều phải đi qua đây, không chỉ `--app-accent`: theo `docs/hop-dong-mau.md` §5\n * thì `--app-accent` chỉ để TÔ NỀN, `--app-accent-foreground` là chữ trên nền đặc đó, còn\n * `--app-accent-ink` là chính màu app khi đi lên nền trang. Thiếu cửa cho `--app-accent-ink`\n * thì app đổi accent sang màu khác vẫn để lại bậc mực gold mặc định (#7A5800 ở light /\n * #F5D060 ở dark) ở mọi chỗ dùng `text-app-accent-ink` — sai màu mà không sai chuẩn.\n */\nexport function appAccentStyle(\n app: Pick<AppDescriptor, \"accent\" | \"accentForeground\" | \"accentInk\"> | null | undefined\n) {\n if (!app?.accent && !app?.accentForeground && !app?.accentInk) return undefined;\n const style: Record<string, string> = {};\n if (app.accent) style[\"--app-accent\"] = app.accent;\n if (app.accentForeground) style[\"--app-accent-foreground\"] = app.accentForeground;\n if (app.accentInk) style[\"--app-accent-ink\"] = app.accentInk;\n return style as CSSProperties;\n}\n"],"mappings":"AAkBA,SAAS,uBAAuB,mBAAmB;AAOnD,SAAS,WAAW,OAA8C;AAChE,SAAO,MAAM,QAAS,MAAmB,KAAK;AAChD;AAMO,SAAS,YAAY,KAAuC;AACjE,MAAI,CAAC,OAAO,IAAI,WAAW,EAAG,QAAO,CAAC;AACtC,QAAM,QAAQ,IAAI,CAAC;AACnB,MAAI,SAAS,WAAW,KAAK,EAAG,QAAO;AACvC,SAAO,CAAC,EAAE,IAAI,WAAW,OAAO,IAAiB,CAAC;AACpD;AAGO,SAAS,gBAAgB,QAA+B;AAC7D,QAAM,MAAiB,CAAC;AACxB,QAAM,OAAO,CAAC,UAAqB;AACjC,eAAW,QAAQ,OAAO;AACxB,UAAI,KAAK,IAAI;AACb,UAAI,KAAK,UAAU,OAAQ,MAAK,KAAK,QAAQ;AAAA,IAC/C;AAAA,EACF;AACA,aAAW,SAAS,OAAQ,MAAK,MAAM,KAAK;AAC5C,SAAO;AACT;AAUO,SAAS,gBAAgB,MAAe,UAAkB,WAAsB,CAAC,GAAY;AAClG,MAAI,KAAK,aAAa,OAAW,QAAO,KAAK;AAC7C,MAAI,CAAC,YAAY,CAAC,KAAK,KAAM,QAAO;AACpC,MAAI,aAAa,KAAK,KAAM,QAAO;AACnC,QAAM,yBAAyB,SAAS,KAAK,CAAC,UAAU,MAAM,SAAS,KAAK,QAAQ,aAAa,MAAM,IAAI;AAC3G,MAAI,uBAAwB,QAAO;AACnC,SAAO,KAAK,SAAS,OAAO,SAAS,WAAW,GAAG,KAAK,IAAI,GAAG;AACjE;AAGO,SAAS,kBAAkB,MAAe,UAAkB,WAAsB,CAAC,GAAY;AACpG,MAAI,gBAAgB,MAAM,UAAU,QAAQ,EAAG,QAAO;AACtD,UAAQ,KAAK,YAAY,CAAC,GAAG,KAAK,CAAC,UAAU,kBAAkB,OAAO,UAAU,QAAQ,CAAC;AAC3F;AAOO,SAAS,gBAAgB,MAAwC;AACtE,SAAO,KAAK,OAAO,CAAC,QAAQ,IAAI,aAAa,KAAK;AACpD;AAGO,SAAS,cAAc,MAAwC;AACpE,SAAO,KAAK,OAAO,CAAC,QAAQ,IAAI,aAAa,KAAK;AACpD;AAMO,SAAS,kBAAkB,MAAuB,UAAwC;AAC/F,MAAI,KAAK,WAAW,EAAG,QAAO;AAC9B,QAAM,UAAU,CAAC,GAAG,IAAI,EACrB,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAC5C,KAAK,CAAC,QAAQ,aAAa,IAAI,QAAS,IAAI,SAAS,OAAO,SAAS,WAAW,GAAG,IAAI,IAAI,GAAG,CAAE;AACnG,SAAO,WAAW,KAAK,CAAC,KAAK;AAC/B;AAqBO,SAAS,eACd,KACA;AACA,MAAI,CAAC,KAAK,UAAU,CAAC,KAAK,oBAAoB,CAAC,KAAK,UAAW,QAAO;AACtE,QAAM,QAAgC,CAAC;AACvC,MAAI,IAAI,OAAQ,OAAM,cAAc,IAAI,IAAI;AAC5C,MAAI,IAAI,iBAAkB,OAAM,yBAAyB,IAAI,IAAI;AACjE,MAAI,IAAI,UAAW,OAAM,kBAAkB,IAAI,IAAI;AACnD,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/shell/utils.ts"],"sourcesContent":["/**\n * Hàm thuần dùng chung cho tầng shell — KHÔNG hook, KHÔNG directive\n * (dùng được ở cả server lẫn client).\n */\n\nimport type { CSSProperties } from \"react\";\nimport type { AppDescriptor, NavGroup, NavItem, ShellNav } from \"./types.js\";\n\n/**\n * Tiện ích chuỗi tiếng Việt — **cùng một binding** với entry chính (`@comitor/ui`).\n *\n * Re-export chứ không viết lại. Hai lần repo này đã trả giá cho việc khai lại:\n * `getVietnameseInitials` từng có hai thân hàm (`toUpperCase()` ở đây,\n * `toLocaleUpperCase(\"vi-VN\")` ở Tier 2) nên cùng một tên cho ra hai kết quả tuỳ chỗ import;\n * `normalizeVi` thì có hai khai báo giống nhau chữ-cho-chữ, chưa sai kết quả nhưng là hai chỗ\n * để sửa lệch nhau lần sau. Tên vẫn xuất ở cả hai entry (không phá API của tầng shell), nhưng\n * chỉ còn MỘT thân hàm — xem docs/quyet-dinh-tich-hop.md §28 (\"một tên, một cửa\").\n */\nexport { getVietnameseInitials, normalizeVi } from \"../components/composite/text-utils.js\";\n\n/* ────────────────────────────────────────────────────────────────────────────\n Điều hướng\n ──────────────────────────────────────────────────────────────────────────── */\n\n/** Phân biệt `NavGroup` với `NavItem` khi app truyền mảng phẳng. */\nfunction isNavGroup(value: NavItem | NavGroup): value is NavGroup {\n return Array.isArray((value as NavGroup).items);\n}\n\n/**\n * Chuẩn hoá menu app truyền vào về `NavGroup[]`.\n * Mảng phẳng `NavItem[]` → một nhóm không nhãn (id \"default\").\n */\nexport function toNavGroups(nav: ShellNav | undefined): NavGroup[] {\n if (!nav || nav.length === 0) return [];\n const first = nav[0];\n if (first && isNavGroup(first)) return nav as NavGroup[];\n return [{ id: \"default\", items: nav as NavItem[] }];\n}\n\n/** Gộp mọi item của mọi nhóm thành một mảng phẳng (kể cả mục con). */\nexport function flattenNavItems(groups: NavGroup[]): NavItem[] {\n const out: NavItem[] = [];\n const walk = (items: NavItem[]) => {\n for (const item of items) {\n out.push(item);\n if (item.children?.length) walk(item.children);\n }\n };\n for (const group of groups) walk(group.items);\n return out;\n}\n\n/**\n * Query string hiện tại, theo mọi hình dạng app thường có sẵn trong tay:\n * `useSearchParams()` của Next (một `URLSearchParams`), chuỗi `\"?tab=x\"` / `\"tab=x\"`,\n * hoặc object `searchParams` mà Server Component nhận được.\n */\nexport type NavSearchParams = string | URLSearchParams | Record<string, string | string[] | undefined>;\n\n/** Tách `href` thành đường dẫn + query. Hash bị bỏ: `pathname` không bao giờ chứa nó. */\nfunction splitHref(href: string): { path: string; query: string } {\n const hashAt = href.indexOf(\"#\");\n const noHash = hashAt === -1 ? href : href.slice(0, hashAt);\n const queryAt = noHash.indexOf(\"?\");\n if (queryAt === -1) return { path: noHash, query: \"\" };\n return { path: noHash.slice(0, queryAt), query: noHash.slice(queryAt + 1) };\n}\n\n/** Quy mọi hình dạng `NavSearchParams` về `URLSearchParams`. `null`/`undefined` → `null`. */\nexport function toSearchParams(input: NavSearchParams | null | undefined): URLSearchParams | null {\n if (input === null || input === undefined) return null;\n if (typeof input === \"string\") return new URLSearchParams(input.startsWith(\"?\") ? input.slice(1) : input);\n // Duck-typing thay vì `instanceof`: `ReadonlyURLSearchParams` của Next kế thừa\n // `URLSearchParams`, nhưng `instanceof` còn hỏng khi hai realm khác nhau (test, iframe).\n if (typeof (input as URLSearchParams).getAll === \"function\") return input as URLSearchParams;\n const out = new URLSearchParams();\n for (const [key, value] of Object.entries(input as Record<string, string | string[] | undefined>)) {\n if (value === undefined) continue;\n if (Array.isArray(value)) for (const one of value) out.append(key, one);\n else out.append(key, value);\n }\n return out;\n}\n\n/** Mọi cặp key=value trong `query` đều có mặt ở `current` (khớp TẬP CON, không đòi bằng hệt). */\nfunction queryIsSubsetOf(query: string, current: URLSearchParams): boolean {\n for (const [key, value] of new URLSearchParams(query)) {\n if (!current.getAll(key).includes(value)) return false;\n }\n return true;\n}\n\n/** Đường dẫn hiệu dụng của một href. `\"?tab=x\"` (chỉ có query) = chính trang đang mở. */\nfunction navItemPath(href: string, pathname: string): string {\n const { path } = splitHref(href);\n return path === \"\" ? pathname : path;\n}\n\n/**\n * Active matching \"thông minh\": chỉ dùng `startsWith` khi KHÔNG có mục nào khác\n * khớp CHÍNH XÁC `pathname`. Nếu không, `/tasks` vẫn sáng khi đang đứng ở\n * `/tasks/inbox` (có mục riêng cho nó).\n *\n * ─ QUERY TRONG `href` ─\n * `usePathname()` của Next **không bao giờ** chứa `?query`, nên so thẳng `pathname === href`\n * làm mục kiểu `/cai-dat?tab=thanh-vien` vĩnh viễn không sáng. Query bị tách khỏi `href`\n * trước khi so đường dẫn; phần query chỉ được xét khi app truyền `searchParams`.\n *\n * Không truyền `searchParams` → chỉ so đường dẫn: mọi mục cùng `/cai-dat` cùng sáng. Đó là\n * ĐÚNG mức thông tin đang có (shell không thấy query), và vẫn tốt hơn hẳn việc không mục nào\n * sáng. Muốn đúng từng tab thì truyền `searchParams` — xem prop cùng tên của `ShellProvider`.\n *\n * @param siblings Danh sách mục để đối chiếu khớp chính xác — nên truyền toàn bộ\n * mục của app hiện tại (`flattenNavItems`), không chỉ mục cùng cấp.\n * @param searchParams Query hiện tại (TUỲ CHỌN). Có nó thì mục có query phải khớp TẬP CON\n * (`?tab=a` sáng ở `?tab=a&q=x`), và mục KHÔNG query nhường cho mục cùng\n * đường dẫn có query khớp — tab mặc định không sáng cùng lúc với tab đang mở.\n */\nexport function isNavItemActive(\n item: NavItem,\n pathname: string,\n siblings: NavItem[] = [],\n searchParams?: NavSearchParams | null\n): boolean {\n if (item.isActive !== undefined) return item.isActive;\n if (!pathname || !item.href) return false;\n\n const { query } = splitHref(item.href);\n const itemPath = navItemPath(item.href, pathname);\n const current = toSearchParams(searchParams);\n\n if (pathname === itemPath) {\n if (!current) return true;\n if (query !== \"\") return queryIsSubsetOf(query, current);\n return !siblings.some((other) => {\n if (other.href === item.href) return false;\n const otherQuery = splitHref(other.href).query;\n return (\n otherQuery !== \"\" && navItemPath(other.href, pathname) === pathname && queryIsSubsetOf(otherQuery, current)\n );\n });\n }\n\n // So khớp chính xác của mục khác cũng phải bỏ query, nếu không `/cai-dat?tab=x` sẽ không\n // bao giờ được tính là \"đã có mục khớp đúng\" và mục cha `/cai-dat` sáng nhầm theo startsWith.\n const hasExactMatchElsewhere = siblings.some(\n (other) => other.href !== item.href && splitHref(other.href).path === pathname\n );\n if (hasExactMatchElsewhere) return false;\n return itemPath !== \"/\" && pathname.startsWith(`${itemPath}/`);\n}\n\n/** Mục cha được coi là active khi chính nó hoặc bất kỳ mục con nào active. */\nexport function isNavBranchActive(\n item: NavItem,\n pathname: string,\n siblings: NavItem[] = [],\n searchParams?: NavSearchParams | null\n): boolean {\n if (isNavItemActive(item, pathname, siblings, searchParams)) return true;\n return (item.children ?? []).some((child) => isNavBranchActive(child, pathname, siblings, searchParams));\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n App (trục 2)\n ──────────────────────────────────────────────────────────────────────────── */\n\n/** App đã được cấp quyền (`entitled` khác `false`) — thứ tự giữ nguyên như app truyền vào. */\nexport function getEntitledApps(apps: AppDescriptor[]): AppDescriptor[] {\n return apps.filter((app) => app.entitled !== false);\n}\n\n/** App chưa mở khoá — hiển thị ở nhóm \"Khám phá thêm\" của AppLauncher. */\nexport function getLockedApps(apps: AppDescriptor[]): AppDescriptor[] {\n return apps.filter((app) => app.entitled === false);\n}\n\n/**\n * Suy ra app hiện tại từ `pathname` — khớp prefix DÀI NHẤT trước để\n * \"/crm/deals\" không rơi nhầm vào app có href \"/\".\n *\n * Query trong `href` bị tách ra trước khi so, cùng lý do với `isNavItemActive`: `pathname`\n * không mang query, nên app khai `href: \"/crm?view=board\"` mà so nguyên chuỗi thì không bao\n * giờ được nhận ra. Sắp xếp cũng theo độ dài ĐƯỜNG DẪN (query dài không được đẩy app lên trước).\n */\nexport function resolveCurrentApp(apps: AppDescriptor[], pathname: string): AppDescriptor | null {\n if (apps.length === 0) return null;\n const matched = apps\n .map((app) => ({ app, path: splitHref(app.href).path }))\n .sort((a, b) => b.path.length - a.path.length)\n .find(({ path }) => pathname === path || (path !== \"/\" && path !== \"\" && pathname.startsWith(`${path}/`)))?.app;\n return matched ?? apps[0] ?? null;\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Biến CSS theo app\n\n (Màu avatar KHÔNG còn cửa riêng ở tầng shell: `getAvatarToneClass` — alias một dòng của\n `getAvatarToneClasses`, lệch tên tham số `seed` ⇄ `name` và phải kèm chú thích dặn \"seed\n phải là tên hiển thị\" — đã bị xoá ở 0.2.0. `UserMenu`/`WorkspaceSwitcher` nhập thẳng\n `getAvatarToneClasses` từ `composite/letter-avatar.js`; app dùng `@comitor/ui`.)\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Inline style gán bộ biến accent cho một app cụ thể.\n * Trả về `undefined` khi app không khai gì → giữ nguyên token mặc định của theme.\n *\n * Cả BA vai trò đều phải đi qua đây, không chỉ `--app-accent`: theo `docs/hop-dong-mau.md` §5\n * thì `--app-accent` chỉ để TÔ NỀN, `--app-accent-foreground` là chữ trên nền đặc đó, còn\n * `--app-accent-ink` là chính màu app khi đi lên nền trang. Thiếu cửa cho `--app-accent-ink`\n * thì app đổi accent sang màu khác vẫn để lại bậc mực gold mặc định (#7A5800 ở light /\n * #F5D060 ở dark) ở mọi chỗ dùng `text-app-accent-ink` — sai màu mà không sai chuẩn.\n */\nexport function appAccentStyle(\n app: Pick<AppDescriptor, \"accent\" | \"accentForeground\" | \"accentInk\"> | null | undefined\n) {\n if (!app?.accent && !app?.accentForeground && !app?.accentInk) return undefined;\n const style: Record<string, string> = {};\n if (app.accent) style[\"--app-accent\"] = app.accent;\n if (app.accentForeground) style[\"--app-accent-foreground\"] = app.accentForeground;\n if (app.accentInk) style[\"--app-accent-ink\"] = app.accentInk;\n return style as CSSProperties;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comitor/ui",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Comitor design system — Shadcn/UI tùy biến theo brand Comitor + component dùng chung cho hệ sinh thái SaaS (Tasks, Chat, CRM, HR…)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,9 +64,10 @@
64
64
  "lint:fix": "biome check --write .",
65
65
  "lint:a11y": "node scripts/check-contrast.mjs",
66
66
  "test:cong-a11y": "node scripts/a11y/tu-kiem.mjs",
67
+ "test:nav-active": "node scripts/kiem-nav-active.mjs",
67
68
  "verify:directives": "node scripts/verify-directives.mjs",
68
69
  "check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
69
- "prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run test:cong-a11y && pnpm run lint:a11y"
70
+ "prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run test:nav-active && pnpm run test:cong-a11y && pnpm run lint:a11y"
70
71
  },
71
72
  "peerDependencies": {
72
73
  "lucide-react": ">=0.540.0",
@@ -75,7 +76,7 @@
75
76
  "react": "^19.0.0",
76
77
  "react-dom": "^19.0.0",
77
78
  "react-hook-form": "^7.54.0",
78
- "recharts": "^2.15.0",
79
+ "recharts": ">=2.15.0",
79
80
  "tailwindcss": "^4.1.0"
80
81
  },
81
82
  "peerDependenciesMeta": {
@@ -138,14 +139,14 @@
138
139
  "@types/node": "^22.0.0",
139
140
  "@types/react": "^19.0.0",
140
141
  "@types/react-dom": "^19.0.0",
141
- "lucide-react": "^0.564.0",
142
+ "lucide-react": "^1.33.0",
142
143
  "next": "^16.0.0",
143
144
  "next-themes": "^0.4.6",
144
145
  "publint": "^0.3.21",
145
146
  "react": "^19.0.0",
146
147
  "react-dom": "^19.0.0",
147
148
  "react-hook-form": "^7.54.1",
148
- "recharts": "^2.15.0",
149
+ "recharts": "^3.10.1",
149
150
  "tailwindcss": "^4.2.0",
150
151
  "tsup": "^8.3.5",
151
152
  "typescript": "^5.7.0"