@comitor/ui 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/components/composite/data-table.d.ts +16 -1
- package/dist/components/composite/data-table.js +41 -7
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/status-config.d.ts +9 -1
- package/dist/components/composite/status-config.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/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/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 +4 -4
- package/styles.css +83 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Switch } from "../components/ui/switch.js";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { FONT_SIZE_MODES } from "./display-axis-constants.js";
|
|
6
|
+
import { useDensity, useFontSize } from "./display-axis-provider.js";
|
|
7
|
+
function DensityToggle({
|
|
8
|
+
label = "B\u1ED1 c\u1EE5c g\u1ECDn",
|
|
9
|
+
description = "Gi\u1EA3m kho\u1EA3ng c\xE1ch d\u1ECDc gi\u1EEFa c\xE1c d\xF2ng trong b\u1EA3ng, danh s\xE1ch v\xE0 menu.",
|
|
10
|
+
className
|
|
11
|
+
}) {
|
|
12
|
+
const { density, setDensity, mounted } = useDensity();
|
|
13
|
+
return /* @__PURE__ */ jsx(
|
|
14
|
+
Switch,
|
|
15
|
+
{
|
|
16
|
+
className,
|
|
17
|
+
checked: density === "compact",
|
|
18
|
+
onCheckedChange: (checked) => setDensity(checked ? "compact" : "comfortable"),
|
|
19
|
+
disabled: !mounted,
|
|
20
|
+
label,
|
|
21
|
+
description
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
const DEFAULT_FONT_SIZE_LABELS = {
|
|
26
|
+
sm: "Nh\u1ECF",
|
|
27
|
+
md: "V\u1EEBa",
|
|
28
|
+
lg: "L\u1EDBn"
|
|
29
|
+
};
|
|
30
|
+
function FontSizeControl({
|
|
31
|
+
label = "C\u1EE1 ch\u1EEF",
|
|
32
|
+
optionLabels = DEFAULT_FONT_SIZE_LABELS,
|
|
33
|
+
className
|
|
34
|
+
}) {
|
|
35
|
+
const { fontSize, setFontSize, mounted } = useFontSize();
|
|
36
|
+
return /* @__PURE__ */ jsx(
|
|
37
|
+
"div",
|
|
38
|
+
{
|
|
39
|
+
role: "radiogroup",
|
|
40
|
+
"aria-label": label,
|
|
41
|
+
className: cn("inline-flex items-center gap-1 rounded-md border border-input p-1 text-sm", className),
|
|
42
|
+
children: FONT_SIZE_MODES.map((mode) => {
|
|
43
|
+
const selected = fontSize === mode;
|
|
44
|
+
return /* @__PURE__ */ jsx(
|
|
45
|
+
"button",
|
|
46
|
+
{
|
|
47
|
+
type: "button",
|
|
48
|
+
role: "radio",
|
|
49
|
+
"aria-checked": selected,
|
|
50
|
+
disabled: !mounted,
|
|
51
|
+
onClick: () => setFontSize(mode),
|
|
52
|
+
className: cn(
|
|
53
|
+
"rounded-sm px-3 py-1 text-sm transition-colors duration-150 ease-out",
|
|
54
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
|
|
55
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
56
|
+
selected ? "bg-control-on text-control-check font-medium" : "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
57
|
+
),
|
|
58
|
+
children: optionLabels[mode]
|
|
59
|
+
},
|
|
60
|
+
mode
|
|
61
|
+
);
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
DensityToggle,
|
|
68
|
+
FontSizeControl
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=display-axis-controls.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shell/display-axis-controls.tsx"],"sourcesContent":["\"use client\";\n\nimport { Switch } from \"../components/ui/switch.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { FONT_SIZE_MODES, type FontSizeMode } from \"./display-axis-constants.js\";\nimport { useDensity, useFontSize } from \"./display-axis-provider.js\";\n\n/**\n * Hai control của trục MẬT ĐỘ và trục CỠ CHỮ.\n *\n * ─ VÌ SAO CẢ HAI ĐỀU CÓ `disabled={!mounted}` ─\n * Giá trị thật nằm ở localStorage, mà server không đọc được. Render lần đầu ở client PHẢI khớp\n * HTML server sinh ra, nếu không React báo lệch hydration — nên provider giữ giá trị mặc định cho\n * tới khi effect đầu chạy. Trong khoảnh khắc đó control hiện mặc định chứ không hiện lựa chọn\n * thật, và nếu người dùng kịp bấm thì họ ghi đè lên chính lựa chọn của mình. Khoá cho tới khi\n * `mounted` là cách `ContrastToggle` đã dùng.\n *\n * (DOM thật thì không nháy: script chống nháy của provider đã đặt thuộc tính lên `<html>` trước\n * lần sơn đầu tiên. Chỉ CONTROL là phải chờ.)\n */\n\nexport interface DensityToggleProps {\n label?: string;\n description?: string;\n className?: string;\n}\n\n/**\n * Nút gạt \"bố cục gọn\".\n *\n * ⚠ Đây KHÔNG phải một trường của biểu mẫu cài đặt. Nó có hiệu lực NGAY và tự lưu, giống\n * `ThemeToggle`/`ContrastToggle` — đừng đưa nó vào luồng nháp/\"Lưu thay đổi\". Trộn hai kiểu thì\n * thanh \"Có thay đổi chưa lưu\" sáng lên cho một thay đổi đã có hiệu lực, và nút \"Hoàn tác\" trong\n * toast sẽ lùi bản nháp mà KHÔNG lùi được trục — giao diện tự mâu thuẫn với chính nó.\n */\nexport function DensityToggle({\n label = \"Bố cục gọn\",\n description = \"Giảm khoảng cách dọc giữa các dòng trong bảng, danh sách và menu.\",\n className\n}: DensityToggleProps) {\n const { density, setDensity, mounted } = useDensity();\n\n return (\n <Switch\n className={className}\n checked={density === \"compact\"}\n onCheckedChange={(checked) => setDensity(checked ? \"compact\" : \"comfortable\")}\n disabled={!mounted}\n label={label}\n description={description}\n />\n );\n}\n\nexport interface FontSizeControlProps {\n /** Nhãn của cả nhóm, đọc lên cho trình đọc màn hình. */\n label?: string;\n /** Nhãn từng mức — THỨ TỰ theo `FONT_SIZE_MODES` (`sm`, `md`, `lg`). */\n optionLabels?: Record<FontSizeMode, string>;\n className?: string;\n}\n\nconst DEFAULT_FONT_SIZE_LABELS: Record<FontSizeMode, string> = {\n sm: \"Nhỏ\",\n md: \"Vừa\",\n lg: \"Lớn\"\n};\n\n/**\n * Chọn cỡ chữ — ba mức, một hàng nút.\n *\n * `role=\"radiogroup\"` + `aria-checked` chứ không phải ba nút thường: đây là chọn MỘT trong ba, và\n * trình đọc màn hình cần nghe \"2 trên 3\" chứ không nghe ba nút rời rạc. Dùng `<button>` thật (bàn\n * phím tới được bằng Tab) thay vì roving focus của Radix ToggleGroup — nhóm chỉ có ba mục nên\n * roving focus không đáng cái giá thêm một phụ thuộc.\n *\n * Cỡ chữ của CHÍNH các nút này cố định `text-sm`: nếu chúng phóng theo lựa chọn thì bấm \"Lớn\" sẽ\n * làm cả hàng nút nhảy chỗ ngay dưới ngón tay.\n */\nexport function FontSizeControl({\n label = \"Cỡ chữ\",\n optionLabels = DEFAULT_FONT_SIZE_LABELS,\n className\n}: FontSizeControlProps) {\n const { fontSize, setFontSize, mounted } = useFontSize();\n\n return (\n <div\n role=\"radiogroup\"\n aria-label={label}\n className={cn(\"inline-flex items-center gap-1 rounded-md border border-input p-1 text-sm\", className)}\n >\n {FONT_SIZE_MODES.map((mode) => {\n const selected = fontSize === mode;\n return (\n <button\n key={mode}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n disabled={!mounted}\n onClick={() => setFontSize(mode)}\n className={cn(\n \"rounded-sm px-3 py-1 text-sm transition-colors duration-150 ease-out\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n selected\n ? \"bg-control-on text-control-check font-medium\"\n : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n )}\n >\n {optionLabels[mode]}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AA2CI;AAzCJ,SAAS,cAAc;AACvB,SAAS,UAAU;AACnB,SAAS,uBAA0C;AACnD,SAAS,YAAY,mBAAmB;AA8BjC,SAAS,cAAc;AAAA,EAC5B,QAAQ;AAAA,EACR,cAAc;AAAA,EACd;AACF,GAAuB;AACrB,QAAM,EAAE,SAAS,YAAY,QAAQ,IAAI,WAAW;AAEpD,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,SAAS,YAAY;AAAA,MACrB,iBAAiB,CAAC,YAAY,WAAW,UAAU,YAAY,aAAa;AAAA,MAC5E,UAAU,CAAC;AAAA,MACX;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;AAUA,MAAM,2BAAyD;AAAA,EAC7D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAaO,SAAS,gBAAgB;AAAA,EAC9B,QAAQ;AAAA,EACR,eAAe;AAAA,EACf;AACF,GAAyB;AACvB,QAAM,EAAE,UAAU,aAAa,QAAQ,IAAI,YAAY;AAEvD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,6EAA6E,SAAS;AAAA,MAEnG,0BAAgB,IAAI,CAAC,SAAS;AAC7B,cAAM,WAAW,aAAa;AAC9B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,gBAAc;AAAA,YACd,UAAU,CAAC;AAAA,YACX,SAAS,MAAM,YAAY,IAAI;AAAA,YAC/B,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA;AAAA,cACA,WACI,iDACA;AAAA,YACN;AAAA,YAEC,uBAAa,IAAI;AAAA;AAAA,UAfb;AAAA,QAgBP;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import { DensityMode, FontSizeMode } from './display-axis-constants.js';
|
|
4
|
+
|
|
5
|
+
interface DisplayAxisValue<T extends string> {
|
|
6
|
+
value: T;
|
|
7
|
+
setValue: (next: T) => void;
|
|
8
|
+
/** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */
|
|
9
|
+
mounted: boolean;
|
|
10
|
+
}
|
|
11
|
+
declare const DensityProvider: ({ children, defaultValue, storageKey }: {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
defaultValue?: "compact" | "comfortable" | undefined;
|
|
14
|
+
storageKey?: string;
|
|
15
|
+
}) => React.JSX.Element;
|
|
16
|
+
declare const DensityScript: ({ storageKey }: {
|
|
17
|
+
storageKey?: string;
|
|
18
|
+
}) => React.JSX.Element;
|
|
19
|
+
/** Đọc/đổi mật độ bố cục. */
|
|
20
|
+
declare function useDensity(): {
|
|
21
|
+
density: DensityMode;
|
|
22
|
+
setDensity: (m: DensityMode) => void;
|
|
23
|
+
mounted: boolean;
|
|
24
|
+
};
|
|
25
|
+
declare const FontSizeProvider: ({ children, defaultValue, storageKey }: {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
defaultValue?: "sm" | "md" | "lg" | undefined;
|
|
28
|
+
storageKey?: string;
|
|
29
|
+
}) => React.JSX.Element;
|
|
30
|
+
declare const FontSizeScript: ({ storageKey }: {
|
|
31
|
+
storageKey?: string;
|
|
32
|
+
}) => React.JSX.Element;
|
|
33
|
+
/** Đọc/đổi cỡ chữ. */
|
|
34
|
+
declare function useFontSize(): {
|
|
35
|
+
fontSize: FontSizeMode;
|
|
36
|
+
setFontSize: (m: FontSizeMode) => void;
|
|
37
|
+
mounted: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export { DensityProvider, DensityScript, type DisplayAxisValue, FontSizeProvider, FontSizeScript, useDensity, useFontSize };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_DENSITY,
|
|
6
|
+
DEFAULT_FONT_SIZE,
|
|
7
|
+
DENSITY_ATTRIBUTE,
|
|
8
|
+
DENSITY_STORAGE_KEY,
|
|
9
|
+
FONT_SIZE_ATTRIBUTE,
|
|
10
|
+
FONT_SIZE_STORAGE_KEY,
|
|
11
|
+
isDensityMode,
|
|
12
|
+
isFontSizeMode
|
|
13
|
+
} from "./display-axis-constants.js";
|
|
14
|
+
function createDisplayAxis(config) {
|
|
15
|
+
const Context = createContext(null);
|
|
16
|
+
function AxisScript({ storageKey = config.storageKey }) {
|
|
17
|
+
const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;
|
|
18
|
+
return /* @__PURE__ */ jsx("script", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: script } });
|
|
19
|
+
}
|
|
20
|
+
function Provider({
|
|
21
|
+
children,
|
|
22
|
+
defaultValue = config.defaultValue,
|
|
23
|
+
storageKey = config.storageKey
|
|
24
|
+
}) {
|
|
25
|
+
const [value, setValueState] = useState(defaultValue);
|
|
26
|
+
const [mounted, setMounted] = useState(false);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let stored = null;
|
|
29
|
+
try {
|
|
30
|
+
stored = window.localStorage.getItem(storageKey);
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
if (config.isValue(stored)) setValueState(stored);
|
|
34
|
+
setMounted(true);
|
|
35
|
+
}, [storageKey]);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!mounted) return;
|
|
38
|
+
const root = document.documentElement;
|
|
39
|
+
if (value === config.defaultValue) root.removeAttribute(config.attribute);
|
|
40
|
+
else root.setAttribute(config.attribute, value);
|
|
41
|
+
}, [value, mounted]);
|
|
42
|
+
const setValue = useCallback(
|
|
43
|
+
(next) => {
|
|
44
|
+
setValueState(next);
|
|
45
|
+
try {
|
|
46
|
+
window.localStorage.setItem(storageKey, next);
|
|
47
|
+
} catch {
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[storageKey]
|
|
51
|
+
);
|
|
52
|
+
const ctx = useMemo(() => ({ value, setValue, mounted }), [value, setValue, mounted]);
|
|
53
|
+
return /* @__PURE__ */ jsxs(Context.Provider, { value: ctx, children: [
|
|
54
|
+
/* @__PURE__ */ jsx(AxisScript, { storageKey }),
|
|
55
|
+
children
|
|
56
|
+
] });
|
|
57
|
+
}
|
|
58
|
+
function useAxis() {
|
|
59
|
+
const ctx = useContext(Context);
|
|
60
|
+
if (!ctx) throw new Error(`${config.hookName}() ph\u1EA3i n\u1EB1m trong <${config.providerName}>. B\u1ECDc n\xF3 \u1EDF root layout.`);
|
|
61
|
+
return ctx;
|
|
62
|
+
}
|
|
63
|
+
return { Provider, useAxis, AxisScript };
|
|
64
|
+
}
|
|
65
|
+
const densityAxis = createDisplayAxis({
|
|
66
|
+
attribute: DENSITY_ATTRIBUTE,
|
|
67
|
+
storageKey: DENSITY_STORAGE_KEY,
|
|
68
|
+
defaultValue: DEFAULT_DENSITY,
|
|
69
|
+
isValue: isDensityMode,
|
|
70
|
+
hookName: "useDensity",
|
|
71
|
+
providerName: "DensityProvider"
|
|
72
|
+
});
|
|
73
|
+
const DensityProvider = densityAxis.Provider;
|
|
74
|
+
const DensityScript = densityAxis.AxisScript;
|
|
75
|
+
function useDensity() {
|
|
76
|
+
const { value, setValue, mounted } = densityAxis.useAxis();
|
|
77
|
+
return { density: value, setDensity: setValue, mounted };
|
|
78
|
+
}
|
|
79
|
+
const fontSizeAxis = createDisplayAxis({
|
|
80
|
+
attribute: FONT_SIZE_ATTRIBUTE,
|
|
81
|
+
storageKey: FONT_SIZE_STORAGE_KEY,
|
|
82
|
+
defaultValue: DEFAULT_FONT_SIZE,
|
|
83
|
+
isValue: isFontSizeMode,
|
|
84
|
+
hookName: "useFontSize",
|
|
85
|
+
providerName: "FontSizeProvider"
|
|
86
|
+
});
|
|
87
|
+
const FontSizeProvider = fontSizeAxis.Provider;
|
|
88
|
+
const FontSizeScript = fontSizeAxis.AxisScript;
|
|
89
|
+
function useFontSize() {
|
|
90
|
+
const { value, setValue, mounted } = fontSizeAxis.useAxis();
|
|
91
|
+
return { fontSize: value, setFontSize: setValue, mounted };
|
|
92
|
+
}
|
|
93
|
+
export {
|
|
94
|
+
DensityProvider,
|
|
95
|
+
DensityScript,
|
|
96
|
+
FontSizeProvider,
|
|
97
|
+
FontSizeScript,
|
|
98
|
+
useDensity,
|
|
99
|
+
useFontSize
|
|
100
|
+
};
|
|
101
|
+
//# sourceMappingURL=display-axis-provider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/shell/display-axis-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport {\n DEFAULT_DENSITY,\n DEFAULT_FONT_SIZE,\n DENSITY_ATTRIBUTE,\n DENSITY_STORAGE_KEY,\n type DensityMode,\n FONT_SIZE_ATTRIBUTE,\n FONT_SIZE_STORAGE_KEY,\n type FontSizeMode,\n isDensityMode,\n isFontSizeMode\n} from \"./display-axis-constants.js\";\n\n/**\n * Xưởng dựng TRỤC HIỂN THỊ — dùng chung cho mật độ và cỡ chữ.\n *\n * `ContrastProvider` viết tay ~60 dòng cho trục thứ hai. Trục thứ ba và thứ tư sẽ là hai bản chép\n * gần như y hệt, nên phần chung tách ra đây: cùng cơ chế đọc localStorage sau khi mount, cùng cờ\n * `mounted` chống lệch hydration, cùng script chống nháy trong `<body>`, cùng quy ước GỠ thuộc\n * tính ở giá trị mặc định.\n *\n * ⚠ `ContrastProvider` CỐ Ý chưa chuyển sang xưởng này. Nó là API đã phát hành và có hành vi\n * riêng đã được ghi chép; chuyển nó là một thay đổi có rủi ro mà lượt này không cần tới. Nếu sau\n * này chuyển thì chuyển một mình nó, có bản so đầu ra trước/sau.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` ─\n * Nó giữ MỘT context singleton cho cả cây. Lồng thêm provider với `attribute`/`storageKey` khác\n * thì provider trong cùng thắng và `useTheme()` của app đọc nhầm trục — không lỗi biên dịch, chỉ\n * là nút đổi sáng/tối lặng lẽ ngừng ăn. `contrast-provider.tsx` đã ghi lại đúng cạm bẫy này.\n */\ninterface AxisConfig<T extends string> {\n attribute: string;\n storageKey: string;\n defaultValue: T;\n isValue: (v: unknown) => v is T;\n hookName: string;\n providerName: string;\n}\n\nexport interface DisplayAxisValue<T extends string> {\n value: T;\n setValue: (next: T) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */\n mounted: boolean;\n}\n\nfunction createDisplayAxis<T extends string>(config: AxisConfig<T>) {\n const Context = createContext<DisplayAxisValue<T> | null>(null);\n\n /**\n * Script chống nháy. Phải chạy TRƯỚC lần sơn đầu tiên, nếu không người đã chọn `lg` sẽ thấy một\n * khung hình cỡ mặc định rồi mới nhảy. Effect của React chạy SAU khi sơn nên không dùng được.\n *\n * ⚠ Nằm trong `<body>`, KHÔNG phải `<head>`: React 19 kêu \"Encountered a script tag while\n * rendering React component\" ở mọi lần render client nếu đặt vào head. Trong body thì tương\n * đương về chống nháy vì parser chạy script ngay khi gặp, mà nó đứng trước toàn bộ markup.\n */\n function AxisScript({ storageKey = config.storageKey }: { storageKey?: string }) {\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;\n // biome-ignore lint/security/noDangerouslySetInnerHtml: chuỗi hằng do chính package sinh, không có dữ liệu người dùng.\n return <script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: script }} />;\n }\n\n function Provider({\n children,\n defaultValue = config.defaultValue,\n storageKey = config.storageKey\n }: {\n children: ReactNode;\n defaultValue?: T;\n storageKey?: string;\n }) {\n const [value, setValueState] = useState<T>(defaultValue);\n const [mounted, setMounted] = useState(false);\n\n // Đọc lựa chọn đã lưu SAU khi mount. Trước đó state phải bằng đúng giá trị server đã render,\n // nếu không React báo lệch hydration — DOM thật đã được AxisScript đặt đúng từ trước rồi.\n useEffect(() => {\n let stored: string | null = null;\n try {\n stored = window.localStorage.getItem(storageKey);\n } catch {\n // Safari chế độ riêng tư / localStorage bị chặn: rơi về mặc định, không phải lỗi.\n }\n if (config.isValue(stored)) setValueState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n // Mặc định thì GỠ thuộc tính, không ghi giá trị — xem ghi chú cuối display-axis-constants.ts.\n if (value === config.defaultValue) root.removeAttribute(config.attribute);\n else root.setAttribute(config.attribute, value);\n }, [value, mounted]);\n\n const setValue = useCallback(\n (next: T) => {\n setValueState(next);\n try {\n window.localStorage.setItem(storageKey, next);\n } catch {\n // Không lưu được thì lựa chọn chỉ sống trong phiên này — vẫn tốt hơn là ném lỗi.\n }\n },\n [storageKey]\n );\n\n const ctx = useMemo<DisplayAxisValue<T>>(() => ({ value, setValue, mounted }), [value, setValue, mounted]);\n\n return (\n <Context.Provider value={ctx}>\n <AxisScript storageKey={storageKey} />\n {children}\n </Context.Provider>\n );\n }\n\n function useAxis(): DisplayAxisValue<T> {\n const ctx = useContext(Context);\n // Ném lỗi thay vì trả mặc định: im lặng thì control trông như hỏng mà không chỉ ra nguyên nhân.\n if (!ctx) throw new Error(`${config.hookName}() phải nằm trong <${config.providerName}>. Bọc nó ở root layout.`);\n return ctx;\n }\n\n return { Provider, useAxis, AxisScript };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 3 — MẬT ĐỘ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst densityAxis = createDisplayAxis<DensityMode>({\n attribute: DENSITY_ATTRIBUTE,\n storageKey: DENSITY_STORAGE_KEY,\n defaultValue: DEFAULT_DENSITY,\n isValue: isDensityMode,\n hookName: \"useDensity\",\n providerName: \"DensityProvider\"\n});\n\nexport const DensityProvider = densityAxis.Provider;\nexport const DensityScript = densityAxis.AxisScript;\n\n/** Đọc/đổi mật độ bố cục. */\nexport function useDensity(): { density: DensityMode; setDensity: (m: DensityMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = densityAxis.useAxis();\n return { density: value, setDensity: setValue, mounted };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 4 — CỠ CHỮ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst fontSizeAxis = createDisplayAxis<FontSizeMode>({\n attribute: FONT_SIZE_ATTRIBUTE,\n storageKey: FONT_SIZE_STORAGE_KEY,\n defaultValue: DEFAULT_FONT_SIZE,\n isValue: isFontSizeMode,\n hookName: \"useFontSize\",\n providerName: \"FontSizeProvider\"\n});\n\nexport const FontSizeProvider = fontSizeAxis.Provider;\nexport const FontSizeScript = fontSizeAxis.AxisScript;\n\n/** Đọc/đổi cỡ chữ. */\nexport function useFontSize(): { fontSize: FontSizeMode; setFontSize: (m: FontSizeMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = fontSizeAxis.useAxis();\n return { fontSize: value, setFontSize: setValue, mounted };\n}\n"],"mappings":"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","names":[]}
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -7,6 +7,9 @@ export { ComitorLogo, ComitorLogoProps, ComitorLogoVariant } from './comitor-log
|
|
|
7
7
|
export { CONTRAST_ATTRIBUTE, CONTRAST_MODES, CONTRAST_STORAGE_KEY, ContrastMode, ContrastToggleLabels, DEFAULT_CONTRAST_TOGGLE_LABELS, isContrastMode } from './contrast-constants.js';
|
|
8
8
|
export { ContrastContextValue, ContrastProvider, ContrastProviderProps, ContrastScript, useContrast } from './contrast-provider.js';
|
|
9
9
|
export { ContrastToggle, ContrastToggleProps } from './contrast-toggle.js';
|
|
10
|
+
export { DEFAULT_DENSITY, DEFAULT_FONT_SIZE, DENSITY_ATTRIBUTE, DENSITY_MODES, DENSITY_STORAGE_KEY, DensityMode, FONT_SIZE_ATTRIBUTE, FONT_SIZE_MODES, FONT_SIZE_STORAGE_KEY, FontSizeMode, isDensityMode, isFontSizeMode } from './display-axis-constants.js';
|
|
11
|
+
export { DensityToggle, DensityToggleProps, FontSizeControl, FontSizeControlProps } from './display-axis-controls.js';
|
|
12
|
+
export { DensityProvider, DensityScript, DisplayAxisValue, FontSizeProvider, FontSizeScript, useDensity, useFontSize } from './display-axis-provider.js';
|
|
10
13
|
export { MobileMenu, MobileMenuProps } from './mobile-menu.js';
|
|
11
14
|
export { NotificationsButton, NotificationsButtonProps } from './notifications-button.js';
|
|
12
15
|
export { ShellContextValue, ShellProvider, ShellProviderProps, useShell } from './shell-context.js';
|
package/dist/shell/index.js
CHANGED
|
@@ -7,6 +7,9 @@ export * from "./comitor-logo.js";
|
|
|
7
7
|
export * from "./contrast-constants.js";
|
|
8
8
|
export * from "./contrast-provider.js";
|
|
9
9
|
export * from "./contrast-toggle.js";
|
|
10
|
+
export * from "./display-axis-constants.js";
|
|
11
|
+
export * from "./display-axis-controls.js";
|
|
12
|
+
export * from "./display-axis-provider.js";
|
|
10
13
|
export * from "./mobile-menu.js";
|
|
11
14
|
export * from "./notifications-button.js";
|
|
12
15
|
export * from "./shell-context.js";
|
package/dist/shell/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/index.ts"],"sourcesContent":["/**\n * Entry \"@comitor/ui/shell\" — Tier 3: khung ứng dụng (AppShell, Sidebar, AppHeader,\n * WorkspaceSwitcher, AppLauncher, UserMenu, ThemeProvider…).\n *\n * Tách khỏi entry chính vì tầng này kéo theo `next/navigation` và `next-themes` — hai\n * peer OPTIONAL. App không chạy trên Next thì đừng import entry này (hoặc tự cấp\n * `ShellLinkComponent` riêng qua `ShellProvider`).\n *\n * Toàn bộ file ở đây là client component: có state, có routing, có phím tắt.\n */\n\n/**\n * Toaster bám theo next-themes — về tier thì nó là primitive (`components/ui/sonner.tsx`),\n * nhưng đặt cửa ra ở entry shell vì nó gọi `useTheme()`, mà `next-themes` chỉ là peer\n * OPTIONAL: để ở entry chính là ép mọi app phải cài next-themes.\n * Dùng chung với `ThemeProvider` ở trên. Nếu app KHÔNG dùng next-themes thì lấy composite\n * `Toaster` ở \"@comitor/ui\" và tự truyền prop `theme`.\n */\nexport { SonnerToaster, type SonnerToasterProps } from \"../components/ui/sonner.js\";\nexport * from \"./app-header.js\";\nexport * from \"./app-launcher.js\";\nexport * from \"./app-shell.js\";\nexport * from \"./comitor-lockup.js\";\nexport * from \"./comitor-logo.js\";\n/**\n * Hằng của trục tương phản xuất THẲNG từ module thuần — root layout là Server Component và nó\n * đọc CONTRAST_STORAGE_KEY. Đi vòng qua contrast-provider.tsx (\"use client\") là hằng thành proxy\n * và ra `undefined` mà không có lỗi nào.\n */\nexport * from \"./contrast-constants.js\";\nexport * from \"./contrast-provider.js\";\nexport * from \"./contrast-toggle.js\";\nexport * from \"./mobile-menu.js\";\nexport * from \"./notifications-button.js\";\nexport * from \"./shell-context.js\";\nexport * from \"./shell-link.js\";\nexport * from \"./sidebar.js\";\nexport * from \"./sidebar-group.js\";\nexport * from \"./sidebar-menu-item.js\";\nexport * from \"./theme-provider.js\";\nexport * from \"./theme-toggle.js\";\nexport * from \"./types.js\";\nexport * from \"./user-menu.js\";\nexport * from \"./utils.js\";\nexport * from \"./workspace-switcher.js\";\n"],"mappings":"AAkBA,SAAS,qBAA8C;AACvD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAMd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/index.ts"],"sourcesContent":["/**\n * Entry \"@comitor/ui/shell\" — Tier 3: khung ứng dụng (AppShell, Sidebar, AppHeader,\n * WorkspaceSwitcher, AppLauncher, UserMenu, ThemeProvider…).\n *\n * Tách khỏi entry chính vì tầng này kéo theo `next/navigation` và `next-themes` — hai\n * peer OPTIONAL. App không chạy trên Next thì đừng import entry này (hoặc tự cấp\n * `ShellLinkComponent` riêng qua `ShellProvider`).\n *\n * Toàn bộ file ở đây là client component: có state, có routing, có phím tắt.\n */\n\n/**\n * Toaster bám theo next-themes — về tier thì nó là primitive (`components/ui/sonner.tsx`),\n * nhưng đặt cửa ra ở entry shell vì nó gọi `useTheme()`, mà `next-themes` chỉ là peer\n * OPTIONAL: để ở entry chính là ép mọi app phải cài next-themes.\n * Dùng chung với `ThemeProvider` ở trên. Nếu app KHÔNG dùng next-themes thì lấy composite\n * `Toaster` ở \"@comitor/ui\" và tự truyền prop `theme`.\n */\nexport { SonnerToaster, type SonnerToasterProps } from \"../components/ui/sonner.js\";\nexport * from \"./app-header.js\";\nexport * from \"./app-launcher.js\";\nexport * from \"./app-shell.js\";\nexport * from \"./comitor-lockup.js\";\nexport * from \"./comitor-logo.js\";\n/**\n * Hằng của trục tương phản xuất THẲNG từ module thuần — root layout là Server Component và nó\n * đọc CONTRAST_STORAGE_KEY. Đi vòng qua contrast-provider.tsx (\"use client\") là hằng thành proxy\n * và ra `undefined` mà không có lỗi nào.\n */\nexport * from \"./contrast-constants.js\";\nexport * from \"./contrast-provider.js\";\nexport * from \"./contrast-toggle.js\";\n/* Cùng lý do như trên — hằng của trục MẬT ĐỘ và CỠ CHỮ xuất thẳng từ module thuần, TRƯỚC provider. */\nexport * from \"./display-axis-constants.js\";\nexport * from \"./display-axis-controls.js\";\nexport * from \"./display-axis-provider.js\";\nexport * from \"./mobile-menu.js\";\nexport * from \"./notifications-button.js\";\nexport * from \"./shell-context.js\";\nexport * from \"./shell-link.js\";\nexport * from \"./sidebar.js\";\nexport * from \"./sidebar-group.js\";\nexport * from \"./sidebar-menu-item.js\";\nexport * from \"./theme-provider.js\";\nexport * from \"./theme-toggle.js\";\nexport * from \"./types.js\";\nexport * from \"./user-menu.js\";\nexport * from \"./utils.js\";\nexport * from \"./workspace-switcher.js\";\n"],"mappings":"AAkBA,SAAS,qBAA8C;AACvD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAMd,cAAc;AACd,cAAc;AACd,cAAc;AAEd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
|
|
@@ -82,6 +82,14 @@ interface ShellProviderProps {
|
|
|
82
82
|
* ```
|
|
83
83
|
*/
|
|
84
84
|
searchParams?: NavSearchParams | null;
|
|
85
|
+
/**
|
|
86
|
+
* Ở 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
|
|
87
|
+
* bên thu gọn sẵn. Mặc định `true`.
|
|
88
|
+
*
|
|
89
|
+
* Đặ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
|
|
90
|
+
* màn hình đó. Lựa chọn ĐÃ LƯU của người dùng luôn thắng cờ này.
|
|
91
|
+
*/
|
|
92
|
+
collapseSidebarOnTablet?: boolean;
|
|
85
93
|
/** Component link thay `next/link` (app không-Next). */
|
|
86
94
|
LinkComponent?: ShellLinkComponent;
|
|
87
95
|
/** Gọi sau mỗi lần người dùng bấm một mục điều hướng (analytics, đóng panel…). */
|
|
@@ -107,6 +115,6 @@ interface ShellProviderProps {
|
|
|
107
115
|
* (Storybook, test). Mọi điều hướng chủ động là việc của app qua callback
|
|
108
116
|
* `onWorkspaceChange` / `onAppSelect` / `onNavigate`; link tĩnh đi qua `LinkComponent`.
|
|
109
117
|
*/
|
|
110
|
-
declare function ShellProvider({ workspaces, currentWorkspaceId, onWorkspaceChange, onCreateWorkspace, onWorkspaceSettings, onInviteMembers, apps, currentAppId, onAppSelect, onUpsell, nav, pathname: pathnameProp, searchParams: searchParamsProp, LinkComponent, onNavigate, user, onLogout, defaultSidebarCollapsed, persistSidebarState, enableShortcuts, labels: labelsProp, children }: ShellProviderProps): React.JSX.Element;
|
|
118
|
+
declare function ShellProvider({ workspaces, currentWorkspaceId, onWorkspaceChange, onCreateWorkspace, onWorkspaceSettings, onInviteMembers, apps, currentAppId, onAppSelect, onUpsell, nav, pathname: pathnameProp, searchParams: searchParamsProp, LinkComponent, onNavigate, user, onLogout, defaultSidebarCollapsed, collapseSidebarOnTablet, persistSidebarState, enableShortcuts, labels: labelsProp, children }: ShellProviderProps): React.JSX.Element;
|
|
111
119
|
|
|
112
120
|
export { type ShellContextValue, ShellProvider, type ShellProviderProps, useShell };
|
|
@@ -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":"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","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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comitor/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
"lint": "biome check .",
|
|
64
64
|
"lint:fix": "biome check --write .",
|
|
65
65
|
"lint:a11y": "node scripts/check-contrast.mjs",
|
|
66
|
-
"test:
|
|
67
|
-
"test:nav-active": "node scripts/
|
|
66
|
+
"test:a11y-gate": "node scripts/a11y/self-test.mjs",
|
|
67
|
+
"test:nav-active": "node scripts/check-nav-active.mjs",
|
|
68
68
|
"verify:directives": "node scripts/verify-directives.mjs",
|
|
69
69
|
"smoke:entries": "node scripts/smoke-entries.mjs",
|
|
70
70
|
"check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
|
|
71
|
-
"prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:
|
|
71
|
+
"prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:a11y-gate && pnpm run lint:a11y"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"lucide-react": ">=0.540.0",
|