@burojs/ui-shadcn 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/dist/candidates-gate-Bb70LPAy.js +135 -0
- package/dist/candidates-gate-Bb70LPAy.js.map +1 -0
- package/dist/candidates-gate-D33jXLHO.cjs +153 -0
- package/dist/candidates-gate-D33jXLHO.cjs.map +1 -0
- package/dist/candidates-relation-picker-B116Bmqi.js +31 -0
- package/dist/candidates-relation-picker-B116Bmqi.js.map +1 -0
- package/dist/candidates-relation-picker-Cy0rTxvI.cjs +31 -0
- package/dist/candidates-relation-picker-Cy0rTxvI.cjs.map +1 -0
- package/dist/{check-confirm-dialog-BRByTFpg.cjs → check-confirm-dialog--gPFnEeg.cjs} +27 -14
- package/dist/check-confirm-dialog--gPFnEeg.cjs.map +1 -0
- package/dist/{check-confirm-dialog-CkaVTPT9.js → check-confirm-dialog-bRT8lt0d.js} +28 -15
- package/dist/check-confirm-dialog-bRT8lt0d.js.map +1 -0
- package/dist/{filter-builder-DOnaXNfP.js → filter-builder-Bhn-dhZV.js} +30 -14
- package/dist/filter-builder-Bhn-dhZV.js.map +1 -0
- package/dist/{filter-builder-B3mZFL-K.cjs → filter-builder-nXqtM_h4.cjs} +39 -11
- package/dist/filter-builder-nXqtM_h4.cjs.map +1 -0
- package/dist/{flow-runtime-B6zOHYqI.cjs → flow-runtime-D3avVH0I.cjs} +3 -3
- package/dist/{flow-runtime-B6zOHYqI.cjs.map → flow-runtime-D3avVH0I.cjs.map} +1 -1
- package/dist/{flow-runtime-D8h99FSg.js → flow-runtime-s6lkRPKF.js} +3 -3
- package/dist/{flow-runtime-D8h99FSg.js.map → flow-runtime-s6lkRPKF.js.map} +1 -1
- package/dist/{flow-screen-CmbARSqg.js → flow-screen-CCvgDI9G.js} +11 -8
- package/dist/flow-screen-CCvgDI9G.js.map +1 -0
- package/dist/{flow-screen-CX05b3G9.cjs → flow-screen-Ci_Sik4Y.cjs} +10 -7
- package/dist/flow-screen-Ci_Sik4Y.cjs.map +1 -0
- package/dist/index.cjs +577 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -103
- package/dist/index.d.ts +181 -103
- package/dist/index.js +567 -45
- package/dist/index.js.map +1 -1
- package/dist/{inline-flow-host-C3H2UpSA.js → inline-flow-host-B6FI0ctQ.js} +3 -3
- package/dist/{inline-flow-host-C3H2UpSA.js.map → inline-flow-host-B6FI0ctQ.js.map} +1 -1
- package/dist/{inline-flow-host-B7U1OJUe.cjs → inline-flow-host-DV1et3c6.cjs} +3 -3
- package/dist/{inline-flow-host-B7U1OJUe.cjs.map → inline-flow-host-DV1et3c6.cjs.map} +1 -1
- package/dist/label-66W1qbke.js.map +1 -1
- package/dist/label-w6JYf2cF.cjs.map +1 -1
- package/dist/markdown/index.d.cts +2 -3
- package/dist/markdown/index.d.ts +2 -3
- package/dist/{page-flow-route-CQ7HsbBm.cjs → page-flow-route-hdnULWqd.cjs} +6 -6
- package/dist/{page-flow-route-CQ7HsbBm.cjs.map → page-flow-route-hdnULWqd.cjs.map} +1 -1
- package/dist/{page-flow-route-CyXSvXSE.js → page-flow-route-i3ZfJ1sd.js} +6 -6
- package/dist/{page-flow-route-CyXSvXSE.js.map → page-flow-route-i3ZfJ1sd.js.map} +1 -1
- package/dist/{place-node-SIp4XYQH.cjs → place-node-C5SmIAYU.cjs} +441 -135
- package/dist/place-node-C5SmIAYU.cjs.map +1 -0
- package/dist/{place-node--GdsP688.js → place-node-NraNYKPx.js} +420 -138
- package/dist/place-node-NraNYKPx.js.map +1 -0
- package/dist/{related-list-cards-B6x9HQQp.js → related-list-cards-BWZ9GfOG.js} +3 -3
- package/dist/{related-list-cards-B6x9HQQp.js.map → related-list-cards-BWZ9GfOG.js.map} +1 -1
- package/dist/{related-list-cards-nxuODlIA.cjs → related-list-cards-CFq3meUn.cjs} +3 -3
- package/dist/{related-list-cards-nxuODlIA.cjs.map → related-list-cards-CFq3meUn.cjs.map} +1 -1
- package/dist/{related-list-timeline-BYJKEbmA.cjs → related-list-timeline-CCzl71aA.cjs} +2 -2
- package/dist/{related-list-timeline-BYJKEbmA.cjs.map → related-list-timeline-CCzl71aA.cjs.map} +1 -1
- package/dist/{related-list-timeline-DEdfd-rr.js → related-list-timeline-yrPsjA3F.js} +2 -2
- package/dist/{related-list-timeline-DEdfd-rr.js.map → related-list-timeline-yrPsjA3F.js.map} +1 -1
- package/dist/{related-rows-zh5f_4QR.cjs → related-rows-ea43qhhy.cjs} +10 -11
- package/dist/related-rows-ea43qhhy.cjs.map +1 -0
- package/dist/{related-rows-B4levHcR.js → related-rows-o0_t-Yce.js} +7 -8
- package/dist/related-rows-o0_t-Yce.js.map +1 -0
- package/dist/{relation-create-BOaUe12G.cjs → relation-create-IA40t1hi.cjs} +10 -8
- package/dist/relation-create-IA40t1hi.cjs.map +1 -0
- package/dist/{relation-create-Bg-eEJLs.js → relation-create-h6F9B4C4.js} +10 -8
- package/dist/relation-create-h6F9B4C4.js.map +1 -0
- package/dist/{relation-many-picker-B-NCEUgX.cjs → relation-many-picker-C8zFEjwd.cjs} +14 -12
- package/dist/relation-many-picker-C8zFEjwd.cjs.map +1 -0
- package/dist/{relation-many-picker-gdAOD-qK.js → relation-many-picker-bvkjkv-k.js} +13 -11
- package/dist/relation-many-picker-bvkjkv-k.js.map +1 -0
- package/dist/{relation-picker-BAY77ivO.js → relation-picker-Z0ZJJ5uS.js} +68 -37
- package/dist/relation-picker-Z0ZJJ5uS.js.map +1 -0
- package/dist/{relation-picker-dialog-M0pQaWlp.cjs → relation-picker-dialog-0IzkjmiA.cjs} +17 -13
- package/dist/relation-picker-dialog-0IzkjmiA.cjs.map +1 -0
- package/dist/{relation-picker-dialog-hA9rYrZB.js → relation-picker-dialog-B6nIiJBf.js} +18 -14
- package/dist/relation-picker-dialog-B6nIiJBf.js.map +1 -0
- package/dist/{relation-picker-Da_uhFvA.cjs → relation-picker-gag0qSR5.cjs} +74 -37
- package/dist/relation-picker-gag0qSR5.cjs.map +1 -0
- package/dist/{resource-page-router-T4vS5gbJ.js → resource-page-router-3RUf9d1j.js} +44 -38
- package/dist/resource-page-router-3RUf9d1j.js.map +1 -0
- package/dist/{resource-page-router-OSQyRA5b.cjs → resource-page-router-PXfMW9nh.cjs} +42 -36
- package/dist/resource-page-router-PXfMW9nh.cjs.map +1 -0
- package/dist/{search-access-CURsWn-U.js → search-access-CPPmfuvy.js} +4 -19
- package/dist/search-access-CPPmfuvy.js.map +1 -0
- package/dist/{search-access-B7G5oR8n.cjs → search-access-Dugvttlg.cjs} +3 -24
- package/dist/search-access-Dugvttlg.cjs.map +1 -0
- package/dist/{toaster-CfCRLgR-.js → toaster-C02XbB9r.js} +30 -59
- package/dist/toaster-C02XbB9r.js.map +1 -0
- package/dist/{toaster-C7CiYA4Z.cjs → toaster-Cr51X7bz.cjs} +30 -83
- package/dist/toaster-Cr51X7bz.cjs.map +1 -0
- package/dist/{use-dialog-filter-state-BDcgS5Oz.js → use-dialog-filter-state-C--oMAZG.js} +2 -2
- package/dist/{use-dialog-filter-state-BDcgS5Oz.js.map → use-dialog-filter-state-C--oMAZG.js.map} +1 -1
- package/dist/{use-dialog-filter-state-BBCC0OEK.cjs → use-dialog-filter-state-Dhe8PcB5.cjs} +2 -2
- package/dist/{use-dialog-filter-state-BBCC0OEK.cjs.map → use-dialog-filter-state-Dhe8PcB5.cjs.map} +1 -1
- package/dist/use-theme-DFlpXils.cjs.map +1 -1
- package/dist/use-theme-dnE3Od3I.js.map +1 -1
- package/package.json +9 -9
- package/dist/candidates-gate-CX2PBu6l.js +0 -63
- package/dist/candidates-gate-CX2PBu6l.js.map +0 -1
- package/dist/candidates-gate-DiFI52r7.cjs +0 -70
- package/dist/candidates-gate-DiFI52r7.cjs.map +0 -1
- package/dist/candidates-relation-picker-CBsVcnPR.js +0 -82
- package/dist/candidates-relation-picker-CBsVcnPR.js.map +0 -1
- package/dist/candidates-relation-picker-Dd_GVLlu.cjs +0 -84
- package/dist/candidates-relation-picker-Dd_GVLlu.cjs.map +0 -1
- package/dist/check-confirm-dialog-BRByTFpg.cjs.map +0 -1
- package/dist/check-confirm-dialog-CkaVTPT9.js.map +0 -1
- package/dist/filter-builder-B3mZFL-K.cjs.map +0 -1
- package/dist/filter-builder-DOnaXNfP.js.map +0 -1
- package/dist/flow-screen-CX05b3G9.cjs.map +0 -1
- package/dist/flow-screen-CmbARSqg.js.map +0 -1
- package/dist/lazy-relation-picker-dialog-2oxnA6IR.js +0 -14
- package/dist/lazy-relation-picker-dialog-2oxnA6IR.js.map +0 -1
- package/dist/lazy-relation-picker-dialog-BfAGKzUG.cjs +0 -19
- package/dist/lazy-relation-picker-dialog-BfAGKzUG.cjs.map +0 -1
- package/dist/peek-filter-CnqHwTMU.cjs +0 -51
- package/dist/peek-filter-CnqHwTMU.cjs.map +0 -1
- package/dist/peek-filter-ErLfO153.js +0 -28
- package/dist/peek-filter-ErLfO153.js.map +0 -1
- package/dist/place-node--GdsP688.js.map +0 -1
- package/dist/place-node-SIp4XYQH.cjs.map +0 -1
- package/dist/related-rows-B4levHcR.js.map +0 -1
- package/dist/related-rows-zh5f_4QR.cjs.map +0 -1
- package/dist/relation-create-BOaUe12G.cjs.map +0 -1
- package/dist/relation-create-Bg-eEJLs.js.map +0 -1
- package/dist/relation-many-picker-B-NCEUgX.cjs.map +0 -1
- package/dist/relation-many-picker-gdAOD-qK.js.map +0 -1
- package/dist/relation-picker-BAY77ivO.js.map +0 -1
- package/dist/relation-picker-Da_uhFvA.cjs.map +0 -1
- package/dist/relation-picker-dialog-M0pQaWlp.cjs.map +0 -1
- package/dist/relation-picker-dialog-hA9rYrZB.js.map +0 -1
- package/dist/resource-page-router-OSQyRA5b.cjs.map +0 -1
- package/dist/resource-page-router-T4vS5gbJ.js.map +0 -1
- package/dist/search-access-B7G5oR8n.cjs.map +0 -1
- package/dist/search-access-CURsWn-U.js.map +0 -1
- package/dist/toaster-C7CiYA4Z.cjs.map +0 -1
- package/dist/toaster-CfCRLgR-.js.map +0 -1
|
@@ -39,57 +39,30 @@ function useLinkComponent() {
|
|
|
39
39
|
return (0, react.useContext)(LinkComponentContext);
|
|
40
40
|
}
|
|
41
41
|
//#endregion
|
|
42
|
-
//#region src/layout/language-storage.ts
|
|
43
|
-
const DEFAULT_LANGUAGE = "ru";
|
|
44
|
-
function loadLanguage(storageKey = _burojs_react.DEFAULT_LANGUAGE_STORAGE_KEY) {
|
|
45
|
-
if (typeof window === "undefined") return "ru";
|
|
46
|
-
try {
|
|
47
|
-
const saved = window.localStorage.getItem(storageKey);
|
|
48
|
-
if (saved === "ru" || saved === "en") return saved;
|
|
49
|
-
} catch {}
|
|
50
|
-
return "ru";
|
|
51
|
-
}
|
|
52
|
-
function saveLanguage(code, storageKey = _burojs_react.DEFAULT_LANGUAGE_STORAGE_KEY) {
|
|
53
|
-
if (typeof window === "undefined") return;
|
|
54
|
-
try {
|
|
55
|
-
window.localStorage.setItem(storageKey, code);
|
|
56
|
-
} catch {}
|
|
57
|
-
}
|
|
58
|
-
function loadLocale(storageKey = _burojs_react.DEFAULT_LANGUAGE_STORAGE_KEY) {
|
|
59
|
-
if (typeof window === "undefined") return void 0;
|
|
60
|
-
try {
|
|
61
|
-
return window.localStorage.getItem(storageKey) ?? void 0;
|
|
62
|
-
} catch {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
function saveLocale(code, storageKey = _burojs_react.DEFAULT_LANGUAGE_STORAGE_KEY) {
|
|
67
|
-
if (typeof window === "undefined") return;
|
|
68
|
-
try {
|
|
69
|
-
window.localStorage.setItem(storageKey, code);
|
|
70
|
-
} catch {}
|
|
71
|
-
}
|
|
72
|
-
//#endregion
|
|
73
42
|
//#region src/lib/friendly-error.ts
|
|
74
43
|
const DEV_FACING = /^Error \(status code:|^Failed to fetch$|^NetworkError|^Load failed$|^TypeError: (Failed to fetch|NetworkError)/i;
|
|
75
44
|
const NETWORK = /Failed to fetch|NetworkError|Load failed|ERR_INTERNET_DISCONNECTED/i;
|
|
76
45
|
const HTTP_TRACE = /\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b[^\n]*->\s*(\d{3})\b/;
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
generic: "Попробуйте повторить действие."
|
|
83
|
-
},
|
|
84
|
-
en: {
|
|
85
|
-
title: "Request failed",
|
|
86
|
-
offline: "Cannot reach the server. Check your connection and try again.",
|
|
87
|
-
unavailable: "The service is temporarily unavailable. Try again in a minute.",
|
|
88
|
-
generic: "Please try again."
|
|
89
|
-
}
|
|
46
|
+
const en = {
|
|
47
|
+
title: "Request failed",
|
|
48
|
+
offline: "Cannot reach the server. Check your connection and try again.",
|
|
49
|
+
unavailable: "The service is temporarily unavailable. Try again in a minute.",
|
|
50
|
+
generic: "Please try again."
|
|
90
51
|
};
|
|
91
|
-
function
|
|
92
|
-
const
|
|
52
|
+
function friendlyStringsFrom(t) {
|
|
53
|
+
const pick = (name) => {
|
|
54
|
+
const key = `requestError.${name}`;
|
|
55
|
+
const value = t(key);
|
|
56
|
+
return value && value !== key ? value : en[name];
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
title: pick("title"),
|
|
60
|
+
offline: pick("offline"),
|
|
61
|
+
unavailable: pick("unavailable"),
|
|
62
|
+
generic: pick("generic")
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function toFriendlyError(message, description, strings) {
|
|
93
66
|
const raw = `${String(message ?? "")} ${String(description ?? "")}`;
|
|
94
67
|
const isDevFacing = DEV_FACING.test(String(message ?? "").trim());
|
|
95
68
|
const isNetwork = NETWORK.test(raw);
|
|
@@ -97,8 +70,8 @@ function toFriendlyError(message, description, locale) {
|
|
|
97
70
|
if (trace) {
|
|
98
71
|
const status = Number(trace[1]);
|
|
99
72
|
return {
|
|
100
|
-
title:
|
|
101
|
-
detail: status >= 500 ?
|
|
73
|
+
title: strings.title,
|
|
74
|
+
detail: status >= 500 ? strings.unavailable : strings.generic,
|
|
102
75
|
replaced: true
|
|
103
76
|
};
|
|
104
77
|
}
|
|
@@ -108,21 +81,20 @@ function toFriendlyError(message, description, locale) {
|
|
|
108
81
|
replaced: false
|
|
109
82
|
};
|
|
110
83
|
return {
|
|
111
|
-
title:
|
|
112
|
-
detail: isNetwork ?
|
|
84
|
+
title: strings.title,
|
|
85
|
+
detail: isNetwork ? strings.offline : String(description ?? "") || strings.generic,
|
|
113
86
|
replaced: true
|
|
114
87
|
};
|
|
115
88
|
}
|
|
116
89
|
//#endregion
|
|
117
90
|
//#region src/notification/notification-provider.ts
|
|
118
91
|
function createNotificationProvider(options = {}) {
|
|
119
|
-
const resolveLocale = options.resolveLocale ?? (() => loadLocale());
|
|
120
92
|
return {
|
|
121
93
|
open: ({ type, message, description }) => {
|
|
122
94
|
let text = message;
|
|
123
95
|
let detail = description;
|
|
124
96
|
if (type === "error") {
|
|
125
|
-
const friendly = toFriendlyError(message, description,
|
|
97
|
+
const friendly = toFriendlyError(message, description, options.resolveStrings?.() ?? en);
|
|
126
98
|
text = friendly.title;
|
|
127
99
|
detail = friendly.detail;
|
|
128
100
|
}
|
|
@@ -138,10 +110,9 @@ function createNotificationProvider(options = {}) {
|
|
|
138
110
|
function Toaster(props) {
|
|
139
111
|
const t = (0, _burojs_react.useOptionalT)();
|
|
140
112
|
const { resolved: theme } = require_use_theme.useTheme();
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const sink = (0, react.useMemo)(() => createNotificationProvider({ resolveLocale: () => localeRef.current ?? loadLocale() }), []);
|
|
113
|
+
const tRef = (0, react.useRef)(t);
|
|
114
|
+
tRef.current = t;
|
|
115
|
+
const sink = (0, react.useMemo)(() => createNotificationProvider({ resolveStrings: () => friendlyStringsFrom(tRef.current) }), []);
|
|
145
116
|
(0, _burojs_react.useRegisterNotificationSink)(sink);
|
|
146
117
|
return (0, react_jsx_runtime.jsx)(sonner.Toaster, {
|
|
147
118
|
containerAriaLabel: t("notifications.region", { defaultValue: "Notifications" }),
|
|
@@ -160,12 +131,6 @@ function Toaster(props) {
|
|
|
160
131
|
});
|
|
161
132
|
}
|
|
162
133
|
//#endregion
|
|
163
|
-
Object.defineProperty(exports, "DEFAULT_LANGUAGE", {
|
|
164
|
-
enumerable: true,
|
|
165
|
-
get: function() {
|
|
166
|
-
return DEFAULT_LANGUAGE;
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
134
|
Object.defineProperty(exports, "LinkComponentProvider", {
|
|
170
135
|
enumerable: true,
|
|
171
136
|
get: function() {
|
|
@@ -208,28 +173,10 @@ Object.defineProperty(exports, "Toaster", {
|
|
|
208
173
|
return Toaster;
|
|
209
174
|
}
|
|
210
175
|
});
|
|
211
|
-
Object.defineProperty(exports, "
|
|
212
|
-
enumerable: true,
|
|
213
|
-
get: function() {
|
|
214
|
-
return loadLanguage;
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
Object.defineProperty(exports, "loadLocale", {
|
|
218
|
-
enumerable: true,
|
|
219
|
-
get: function() {
|
|
220
|
-
return loadLocale;
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
Object.defineProperty(exports, "saveLanguage", {
|
|
224
|
-
enumerable: true,
|
|
225
|
-
get: function() {
|
|
226
|
-
return saveLanguage;
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
Object.defineProperty(exports, "saveLocale", {
|
|
176
|
+
Object.defineProperty(exports, "friendlyStringsFrom", {
|
|
230
177
|
enumerable: true,
|
|
231
178
|
get: function() {
|
|
232
|
-
return
|
|
179
|
+
return friendlyStringsFrom;
|
|
233
180
|
}
|
|
234
181
|
});
|
|
235
182
|
Object.defineProperty(exports, "toFriendlyError", {
|
|
@@ -245,4 +192,4 @@ Object.defineProperty(exports, "useLinkComponent", {
|
|
|
245
192
|
}
|
|
246
193
|
});
|
|
247
194
|
|
|
248
|
-
//# sourceMappingURL=toaster-
|
|
195
|
+
//# sourceMappingURL=toaster-Cr51X7bz.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toaster-Cr51X7bz.cjs","names":["PopoverPrimitive","React","cn","forwardRef","createContext","useContext","toast","useOptionalT","useTheme","useRef","useMemo","SonnerToaster"],"sources":["../src/primitives/popover.tsx","../src/layout/link-component.tsx","../src/lib/friendly-error.ts","../src/notification/notification-provider.ts","../src/notification/toaster.tsx"],"sourcesContent":["'use client';\n\nimport * as React from 'react';\nimport * as PopoverPrimitive from '@radix-ui/react-popover';\nimport { cn } from '../lib/cn.js';\n\n/**\n * Thin wrapper around Radix Popover, mirroring the structure of the\n * Dialog / Select primitives in this package so the Tailwind tokens\n * stay aligned. Re-exports `Root` / `Trigger` / `Anchor` / `Close`\n * directly (no styling needed) and styles `Content` to match the\n * shadcn Popover defaults.\n */\n\nconst Popover = PopoverPrimitive.Root;\nconst PopoverTrigger = PopoverPrimitive.Trigger;\nconst PopoverAnchor = PopoverPrimitive.Anchor;\nconst PopoverClose = PopoverPrimitive.Close;\n\nexport type PopoverContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>;\n\nconst PopoverContent = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Content>,\n PopoverContentProps\n>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n ref={ref}\n align={align}\n sideOffset={sideOffset}\n className={cn(\n 'z-50 w-72 rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-hidden',\n 'duration-[var(--duration-overlay)] ease-[var(--ease-overlay)]',\n 'data-[state=open]:animate-in data-[state=closed]:animate-out',\n 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',\n 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',\n 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',\n className,\n )}\n {...props}\n />\n </PopoverPrimitive.Portal>\n));\nPopoverContent.displayName = PopoverPrimitive.Content.displayName;\n\nexport { Popover, PopoverTrigger, PopoverAnchor, PopoverClose, PopoverContent };\n","'use client';\nimport {\n type AnchorHTMLAttributes,\n type ComponentType,\n type ReactElement,\n type ReactNode,\n createContext,\n forwardRef,\n useContext,\n} from 'react';\n\n/**\n * Props every `LinkComponent` must accept. We intentionally mirror the\n * native `<a>` API surface so adopters writing a small wrapper around\n * their router's link primitive (next/link, react-router, TanStack\n * Router, etc.) can pass them straight through.\n *\n * `next/link` already accepts these names verbatim. `react-router`'s\n * `<Link>` uses `to` instead of `href` — adopters wrap once:\n *\n * const RouterLink: LinkComponent = ({ href, ...rest }) =>\n * <Link to={href} {...rest} />;\n */\nexport type LinkComponentProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {\n href: string;\n children?: ReactNode;\n};\n\nexport type LinkComponent = ComponentType<LinkComponentProps>;\n\n/**\n * Default fallback — a plain `<a href>` anchor. Triggers a full\n * document load. Adopters that ship a SPA router (Next.js App Router,\n * react-router) install their router's `<Link>` via\n * `<LinkComponentProvider>` so internal navigation stays client-side.\n */\nconst DefaultLinkComponent: LinkComponent = forwardRef<HTMLAnchorElement, LinkComponentProps>(\n function DefaultLinkComponent({ children, ...rest }, ref) {\n return (\n <a ref={ref} {...rest}>\n {children}\n </a>\n );\n },\n);\n\nconst LinkComponentContext = createContext<LinkComponent>(DefaultLinkComponent);\n\nexport interface LinkComponentProviderProps {\n /**\n * The router-aware Link component to use throughout `@burojs/ui-shadcn` for\n * internal navigation. Pass `Link` from `next/link` for Next.js App Router;\n * wrap your router's link primitive once for react-router / TanStack Router.\n */\n linkComponent: LinkComponent;\n children: ReactNode;\n}\n\n/**\n * Install a router-aware `LinkComponent` for every framework component that\n * renders an internal navigation link (sidebar entries, action links, relation\n * cells, the list-empty Create CTA, …). Without it the framework falls back to\n * a raw `<a>` — correct, but a full document reload on every click.\n *\n * This is a link component and nothing else: the location and imperative\n * navigation are the `RouterAdapter`'s (`RouterAdapterProvider` and\n * `useRouteNavigate` from `@burojs/react`), the one router seam.\n *\n * import Link from 'next/link';\n * <LinkComponentProvider linkComponent={Link}>\n * <App />\n * </LinkComponentProvider>\n */\nexport function LinkComponentProvider({\n linkComponent,\n children,\n}: LinkComponentProviderProps): ReactElement {\n return (\n <LinkComponentContext.Provider value={linkComponent}>{children}</LinkComponentContext.Provider>\n );\n}\n\n/**\n * Read the current `LinkComponent`. Call this from any framework\n * component that needs to render an internal-navigation link, then\n * use the returned component in JSX:\n *\n * const RouterLink = useLinkComponent();\n * return <RouterLink href={href}>{label}</RouterLink>;\n *\n * Always returns a usable component — the default fallback emits a\n * raw `<a href>` so the component never has to null-check.\n */\nexport function useLinkComponent(): LinkComponent {\n return useContext(LinkComponentContext);\n}\n","/**\n * Turn a developer-facing error string into something a user can act on.\n *\n * Two surfaces need this and they are reached by different code paths — the\n * notification provider (a legacy query adapter's default query-error\n * toast) and the inline\n * error panel a list renders in place of its rows. Fixing only the first left\n * `/document_document` still printing a raw English «Failed to fetch» under a\n * Russian heading, which is how this ended up in a second acceptance round.\n * One helper, both call sites.\n *\n * Anything we do not recognise is passed through unchanged: a message written\n * by the adopter's own `onError` is almost certainly better than a generic\n * substitute.\n */\n\n/** A legacy query adapter's own untranslated wording plus the raw fetch/network failures. */\nconst DEV_FACING =\n /^Error \\(status code:|^Failed to fetch$|^NetworkError|^Load failed$|^TypeError: (Failed to fetch|NetworkError)/i;\nconst NETWORK = /Failed to fetch|NetworkError|Load failed|ERR_INTERNET_DISCONNECTED/i;\n/**\n * A machine transport trace, e.g.\n * «Temporal GET /namespaces/default/schedules?maximumPageSize=10 -> 502»\n *\n * A data provider that reports which call failed is doing the right thing —\n * that string belongs in a bug report. It does not belong in front of an\n * operator as the entire explanation of why their page is empty, which is\n * exactly what `/schedules` did when the Temporal cluster went down.\n *\n * Recognised by an HTTP verb followed by an arrow and a status code; nothing\n * an adopter would hand-write looks like this, so the pass-through contract\n * for adopter-authored messages is preserved.\n */\nconst HTTP_TRACE = /\\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\\b[^\\n]*->\\s*(\\d{3})\\b/;\n\n/** The four sentences a recognised failure is replaced with: the bundle keys `requestError.*`. */\nexport interface FriendlyErrorStrings {\n readonly title: string;\n readonly offline: string;\n readonly unavailable: string;\n readonly generic: string;\n}\n\n/**\n * The framework's code language: used where no i18n is configured, and for any\n * key the active bundles do not carry.\n */\nexport const en: FriendlyErrorStrings = {\n title: 'Request failed',\n offline: 'Cannot reach the server. Check your connection and try again.',\n unavailable: 'The service is temporarily unavailable. Try again in a minute.',\n generic: 'Please try again.',\n};\n\n/**\n * Resolve {@link FriendlyErrorStrings} through a translate function (`useT()`,\n * `useOptionalT()`), taking the English default for each key it cannot find.\n */\nexport function friendlyStringsFrom(t: (key: string) => string): FriendlyErrorStrings {\n const pick = (name: keyof FriendlyErrorStrings): string => {\n const key = `requestError.${name}`;\n const value = t(key);\n return value && value !== key ? value : en[name];\n };\n return {\n title: pick('title'),\n offline: pick('offline'),\n unavailable: pick('unavailable'),\n generic: pick('generic'),\n };\n}\n\nexport interface FriendlyError {\n /** Short headline. Empty when the original message should be kept. */\n title: string;\n /** Explanation the user can act on. */\n detail: string;\n /** True when the input was recognised as developer-facing and replaced. */\n replaced: boolean;\n}\n\n/**\n * @param strings the sentences to substitute, already in the active language:\n * {@link friendlyStringsFrom} builds them from the app's translate function,\n * and {@link en} is the default where none is configured.\n */\nexport function toFriendlyError(\n message: unknown,\n description: unknown,\n strings: FriendlyErrorStrings,\n): FriendlyError {\n const raw = `${String(message ?? '')} ${String(description ?? '')}`;\n const isDevFacing = DEV_FACING.test(String(message ?? '').trim());\n const isNetwork = NETWORK.test(raw);\n const trace = HTTP_TRACE.exec(String(message ?? '').trim());\n\n if (trace) {\n // 5xx is the server's problem and waiting is the right advice; anything\n // else is at least not something the operator can read a URL out of.\n const status = Number(trace[1]);\n return {\n title: strings.title,\n detail: status >= 500 ? strings.unavailable : strings.generic,\n replaced: true,\n };\n }\n\n if (!isDevFacing && !isNetwork) {\n return {\n title: String(message ?? ''),\n detail: String(description ?? ''),\n replaced: false,\n };\n }\n return {\n title: strings.title,\n detail: isNetwork ? strings.offline : String(description ?? '') || strings.generic,\n replaced: true,\n };\n}\n","import type { NotificationSinkMessage } from '@burojs/react';\nimport { toast } from 'sonner';\nimport { type FriendlyErrorStrings, en, toFriendlyError } from '../lib/friendly-error.js';\n\n/** `<Toaster>`'s sink: `@burojs/react`'s `NotificationSink`, plus `close`. */\nexport interface NotificationSink {\n open(args: NotificationSinkMessage): void;\n close(key: string): void;\n}\n\n/**\n * A legacy query adapter can build its default query-error notification outside\n * our i18n bundle: the user saw «Error (status code: undefined)» / «Failed to fetch», plus it duplicated\n * the inline error state a list already renders. We can't localize a string we\n * don't own, so `toFriendlyError` recognises it and substitutes plain language.\n * The SAME helper backs the list's inline panel — see friendly-error.ts for why\n * that matters.\n */\nexport interface NotificationProviderOptions {\n /**\n * The sentences an error toast is rewritten with (`requestError.*` in the active bundle), or\n * `undefined` for the English defaults. `<Toaster>` passes one built from the app's translate\n * function; a sink built outside React passes its own.\n *\n * ⚠️ It is a FUNCTION, not a value, and that is load-bearing: both examples\n * build this sink once (`React.useMemo(…, [])`) and it has no React context\n * to subscribe to, so strings captured at construction would freeze in\n * whatever language was active on the first render. Called per toast instead.\n */\n readonly resolveStrings?: () => FriendlyErrorStrings | undefined;\n}\n\nexport function createNotificationProvider(\n options: NotificationProviderOptions = {},\n): NotificationSink {\n return {\n open: ({ type, message, description }) => {\n let text = message;\n let detail = description;\n if (type === 'error') {\n const friendly = toFriendlyError(message, description, options.resolveStrings?.() ?? en);\n text = friendly.title;\n detail = friendly.detail;\n }\n // `info` is a plain notice that dismisses itself — never `toast.loading`,\n // whose spinner stays on screen until someone closes it.\n const fn = type === 'error' ? toast.error : type === 'info' ? toast.info : toast.success;\n fn(text, { description: detail });\n },\n close: (key: string) => {\n toast.dismiss(key);\n },\n };\n}\n","'use client';\nimport { useOptionalT, useRegisterNotificationSink } from '@burojs/react';\nimport { useMemo, useRef } from 'react';\nimport { Toaster as SonnerToaster, type ToasterProps as SonnerProps, toast } from 'sonner';\nimport { friendlyStringsFrom } from '../lib/friendly-error.js';\nimport { useTheme } from '../layout/use-theme.js';\nimport { createNotificationProvider } from './notification-provider.js';\n\nexport interface ToasterProps extends SonnerProps {}\n\n/**\n * Shadcn-canonical Sonner setup, and — inside a `<BuroProvider>` — the app's\n * notification sink: the dispatcher's default `notify` (every handler's\n * `notify`, and a thrown handler's error) is delivered here. Outside a\n * provider it registers nothing and still renders anything sent with `toast()`.\n *\n * Bound to design tokens via `toastOptions.classNames`, so toasts inherit the\n * app's light/dark theme through the same CSS-variable cascade as the rest of\n * the UI; `theme` is reactive via `useTheme()`. The error sentences come from\n * the ACTIVE bundle (`requestError.*`), read per toast so a language switch is\n * honoured; with no i18n provider they are the English defaults.\n */\nexport function Toaster(props: ToasterProps): React.ReactElement {\n // `useOptionalT`: the toaster may sit outside a BuroProvider, so it must\n // degrade rather than throw.\n const t = useOptionalT();\n const { resolved: theme } = useTheme();\n const tRef = useRef(t);\n tRef.current = t;\n const sink = useMemo(\n () => createNotificationProvider({ resolveStrings: () => friendlyStringsFrom(tRef.current) }),\n [],\n );\n useRegisterNotificationSink(sink);\n\n return (\n <SonnerToaster\n /*\n ⚠️ Sonner's default accessible name is \"Notifications alt+T\" and reaches\n the accessibility tree on every page; it is replaced by the bundle's\n `notifications.region`. The literal fallback is English, like every\n framework default.\n */\n containerAriaLabel={t('notifications.region', { defaultValue: 'Notifications' })}\n theme={theme}\n position=\"top-right\"\n closeButton\n className=\"toaster group\"\n toastOptions={{\n classNames: {\n toast:\n 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',\n description: 'group-[.toast]:text-muted-foreground',\n actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',\n cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',\n closeButton:\n 'group-[.toast]:bg-background group-[.toast]:text-muted-foreground group-[.toast]:border-border group-[.toast]:hover:bg-muted group-[.toast]:hover:text-foreground',\n },\n }}\n {...props}\n />\n );\n}\n\nexport { toast };\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as narrowSearchDecl, t as namesHiddenField } from "./search-access-
|
|
1
|
+
import { n as narrowSearchDecl, t as namesHiddenField } from "./search-access-CPPmfuvy.js";
|
|
2
2
|
import { EMPTY_FILTER_GROUP, composeListFilter } from "@burojs/core";
|
|
3
3
|
import { useApp, useFilterFields, useListAccess } from "@burojs/react";
|
|
4
4
|
import { useCallback, useMemo, useState } from "react";
|
|
@@ -84,4 +84,4 @@ function useDialogFilterState(resource, options) {
|
|
|
84
84
|
//#endregion
|
|
85
85
|
export { useDialogFilterState as t };
|
|
86
86
|
|
|
87
|
-
//# sourceMappingURL=use-dialog-filter-state-
|
|
87
|
+
//# sourceMappingURL=use-dialog-filter-state-C--oMAZG.js.map
|
package/dist/{use-dialog-filter-state-BDcgS5Oz.js.map → use-dialog-filter-state-C--oMAZG.js.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-dialog-filter-state-
|
|
1
|
+
{"version":3,"file":"use-dialog-filter-state-C--oMAZG.js","names":[],"sources":["../src/fields/use-dialog-filter-state.ts"],"sourcesContent":["import {\n EMPTY_FILTER_GROUP,\n composeListFilter,\n type ErasedResourceHandle,\n type FilterExpression,\n type FilterGroup,\n type FixedFilterInput,\n type ResolvedFixedFilter,\n type SearchDecl,\n} from '@burojs/core';\nimport { useApp, useFilterFields, useListAccess, type FilterFieldSpec } from '@burojs/react';\nimport { namesHiddenField, narrowSearchDecl } from './search-access.js';\nimport { useCallback, useMemo, useState } from 'react';\n\n/** The portable erased AST — the only shape this module deals in. */\ntype ErasedExpression = FilterExpression<ErasedResourceHandle>;\n\n/** Options for {@link useDialogFilterState}. */\nexport interface UseDialogFilterStateOptions {\n /** Call-site narrowing (e.g. scope the target list to a product group). */\n readonly fixedFilters?: readonly FixedFilterInput[];\n /**\n * Fallback search field, used ONLY when the target resource declares no\n * `views.list.search`. Wrapped into a single-field `SearchFieldsDecl`\n * (`{ fields: [searchField] }`, implicit `contains` — same degenerate form\n * `searchToFilter` already gives a bare field-name string) so the override\n * goes through the exact same `composeListFilter` → `searchToFilter` path\n * as a real declaration, rather than a second hand-built predicate at the\n * call site. The target's own declaration always wins when present — this\n * is a fallback, not an override of a declared search.\n */\n readonly searchField?: string;\n /**\n * Gates the field-derivation walk (`useFilterFields`'s `categorizeField`\n * pass over every field of the resource). Defaults to `true`. A caller\n * whose UI isn't visible yet (e.g. `RelationPickerDialog` before it's\n * opened) should pass `false` to skip that work on every mount /\n * `fixedFilters`-identity change; the derivation recomputes in full the\n * next render where this flips back to `true`.\n */\n readonly enabled?: boolean;\n}\n\n/** Return shape of {@link useDialogFilterState}. */\nexport interface DialogFilterState {\n readonly group: FilterGroup;\n readonly setGroup: (next: FilterGroup) => void;\n readonly search: string;\n readonly setSearch: (next: string) => void;\n readonly page: number;\n readonly setPage: (page: number) => void;\n readonly available: readonly FilterFieldSpec[];\n readonly fixed: readonly ResolvedFixedFilter[];\n /** The single expression to hand the data provider. */\n readonly filter: ErasedExpression | undefined;\n /** Whether the target has a search for this user (no field left means no search box). */\n readonly searchEnabled: boolean;\n /** The read must not be sent: undecided access, or a filter naming a field the user may not view. */\n readonly blocked: boolean;\n readonly reset: () => void;\n}\n\n/**\n * Local (non-URL) filter state for the relation-picker's advanced-search\n * dialog.\n *\n * `useResourceList` cannot serve this: it unconditionally calls\n * `useResourceUrlState`, which throws without a `RouterAdapterProvider` and,\n * when one IS present, writes to `?<resource>.*`. A picker dialog mounted\n * inside a form for resource A, searching TARGET resource B, would then\n * write B's list state into the URL — clobbering a real B list page open on\n * another tab of the same app, and reading back whatever that page last\n * wrote on open. So this hook keeps `group`/`search`/`page` in plain\n * `useState` and reuses the SAME composition core's list controller uses\n * (`composeListFilter`) so the two never drift on what a filter means.\n *\n * No `pinned` slot: pinned controls are a list-PAGE affordance (permanent\n * screen real estate for a couple of always-visible facets); a modal has no\n * spare real estate to spend on them.\n */\nexport function useDialogFilterState(\n resource: string,\n options: UseDialogFilterStateOptions,\n): DialogFilterState {\n const app = useApp();\n const def = app.registry.get(resource);\n const { available, fixed } = useFilterFields(resource, {\n fixedFilters: options.fixedFilters,\n enabled: options.enabled,\n });\n\n const [group, setGroupState] = useState<FilterGroup>(EMPTY_FILTER_GROUP);\n const [search, setSearchState] = useState('');\n const [page, setPage] = useState(1);\n\n // Any change to WHAT is filtered invalidates the current page — the same\n // rule `useResourceList`'s URL-state controller applies to its own\n // group/search setters.\n const setGroup = useCallback((next: FilterGroup) => {\n setGroupState(next);\n setPage(1);\n }, []);\n\n const setSearch = useCallback((next: string) => {\n setSearchState(next);\n setPage(1);\n }, []);\n\n const reset = useCallback(() => {\n setGroupState(EMPTY_FILTER_GROUP);\n setSearchState('');\n setPage(1);\n }, []);\n\n // The target's own declaration always wins; `options.searchField` only\n // fills in when the target declares no search at all. Memoized so an\n // omitted declaration doesn't hand `composeListFilter`'s `filter` memo a\n // fresh `{ fields: [...] }` literal (and therefore a spurious recompute)\n // on every render — see `UseDialogFilterStateOptions.searchField`'s doc.\n const { searchField } = options;\n const access = useListAccess(resource);\n const accessReady = access.status === 'ready';\n const searchDecl = useMemo<SearchDecl | undefined>(() => {\n const declared = def?.views?.list?.search;\n const base =\n declared !== undefined\n ? declared\n : searchField === undefined\n ? undefined\n : { fields: [searchField] };\n return narrowSearchDecl(base, (field) => access.fieldVisible(field));\n }, [def, searchField, access]);\n const searchContext = useMemo(() => ({ resource, def }), [resource, def]);\n\n const filter = useMemo<ErasedExpression | undefined>(\n () => composeListFilter({ fixed, group, search, searchDecl, searchContext }),\n [fixed, group, search, searchDecl, searchContext],\n );\n\n // A read whose composed filter names a field the user may not view is forbidden (an author\n // predicate, or a function search's output); an undecided access sends nothing yet.\n const blocked = !accessReady || namesHiddenField(filter, (field) => access.fieldVisible(field));\n\n return useMemo<DialogFilterState>(\n () => ({\n searchEnabled: searchDecl !== undefined,\n blocked,\n group,\n setGroup,\n search,\n setSearch,\n page,\n setPage,\n available,\n fixed,\n filter,\n reset,\n }),\n [\n group,\n setGroup,\n search,\n setSearch,\n page,\n available,\n fixed,\n filter,\n reset,\n searchDecl,\n blocked,\n ],\n );\n}\n"],"mappings":";;;;;AAgFA,SAAgB,qBACd,UACA,SACmB;CAEnB,MAAM,MADM,OACE,CAAC,CAAC,SAAS,IAAI,QAAQ;CACrC,MAAM,EAAE,WAAW,UAAU,gBAAgB,UAAU;EACrD,cAAc,QAAQ;EACtB,SAAS,QAAQ;CACnB,CAAC;CAED,MAAM,CAAC,OAAO,iBAAiB,SAAsB,kBAAkB;CACvE,MAAM,CAAC,QAAQ,kBAAkB,SAAS,EAAE;CAC5C,MAAM,CAAC,MAAM,WAAW,SAAS,CAAC;CAKlC,MAAM,WAAW,aAAa,SAAsB;EAClD,cAAc,IAAI;EAClB,QAAQ,CAAC;CACX,GAAG,CAAC,CAAC;CAEL,MAAM,YAAY,aAAa,SAAiB;EAC9C,eAAe,IAAI;EACnB,QAAQ,CAAC;CACX,GAAG,CAAC,CAAC;CAEL,MAAM,QAAQ,kBAAkB;EAC9B,cAAc,kBAAkB;EAChC,eAAe,EAAE;EACjB,QAAQ,CAAC;CACX,GAAG,CAAC,CAAC;CAOL,MAAM,EAAE,gBAAgB;CACxB,MAAM,SAAS,cAAc,QAAQ;CACrC,MAAM,cAAc,OAAO,WAAW;CACtC,MAAM,aAAa,cAAsC;EACvD,MAAM,WAAW,KAAK,OAAO,MAAM;EAOnC,OAAO,iBALL,aAAa,KAAA,IACT,WACA,gBAAgB,KAAA,IACd,KAAA,IACA,EAAE,QAAQ,CAAC,WAAW,EAAE,IACD,UAAU,OAAO,aAAa,KAAK,CAAC;CACrE,GAAG;EAAC;EAAK;EAAa;CAAM,CAAC;CAC7B,MAAM,gBAAgB,eAAe;EAAE;EAAU;CAAI,IAAI,CAAC,UAAU,GAAG,CAAC;CAExE,MAAM,SAAS,cACP,kBAAkB;EAAE;EAAO;EAAO;EAAQ;EAAY;CAAc,CAAC,GAC3E;EAAC;EAAO;EAAO;EAAQ;EAAY;CAAa,CAClD;CAIA,MAAM,UAAU,CAAC,eAAe,iBAAiB,SAAS,UAAU,OAAO,aAAa,KAAK,CAAC;CAE9F,OAAO,eACE;EACL,eAAe,eAAe,KAAA;EAC9B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,IACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CACF;AACF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const require_search_access = require("./search-access-
|
|
1
|
+
const require_search_access = require("./search-access-Dugvttlg.cjs");
|
|
2
2
|
let _burojs_core = require("@burojs/core");
|
|
3
3
|
let _burojs_react = require("@burojs/react");
|
|
4
4
|
let react = require("react");
|
|
@@ -89,4 +89,4 @@ Object.defineProperty(exports, "useDialogFilterState", {
|
|
|
89
89
|
}
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
//# sourceMappingURL=use-dialog-filter-state-
|
|
92
|
+
//# sourceMappingURL=use-dialog-filter-state-Dhe8PcB5.cjs.map
|
package/dist/{use-dialog-filter-state-BBCC0OEK.cjs.map → use-dialog-filter-state-Dhe8PcB5.cjs.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-dialog-filter-state-
|
|
1
|
+
{"version":3,"file":"use-dialog-filter-state-Dhe8PcB5.cjs","names":["useApp","useFilterFields","useState","EMPTY_FILTER_GROUP","useCallback","useListAccess","useMemo","narrowSearchDecl","composeListFilter","namesHiddenField"],"sources":["../src/fields/use-dialog-filter-state.ts"],"sourcesContent":["import {\n EMPTY_FILTER_GROUP,\n composeListFilter,\n type ErasedResourceHandle,\n type FilterExpression,\n type FilterGroup,\n type FixedFilterInput,\n type ResolvedFixedFilter,\n type SearchDecl,\n} from '@burojs/core';\nimport { useApp, useFilterFields, useListAccess, type FilterFieldSpec } from '@burojs/react';\nimport { namesHiddenField, narrowSearchDecl } from './search-access.js';\nimport { useCallback, useMemo, useState } from 'react';\n\n/** The portable erased AST — the only shape this module deals in. */\ntype ErasedExpression = FilterExpression<ErasedResourceHandle>;\n\n/** Options for {@link useDialogFilterState}. */\nexport interface UseDialogFilterStateOptions {\n /** Call-site narrowing (e.g. scope the target list to a product group). */\n readonly fixedFilters?: readonly FixedFilterInput[];\n /**\n * Fallback search field, used ONLY when the target resource declares no\n * `views.list.search`. Wrapped into a single-field `SearchFieldsDecl`\n * (`{ fields: [searchField] }`, implicit `contains` — same degenerate form\n * `searchToFilter` already gives a bare field-name string) so the override\n * goes through the exact same `composeListFilter` → `searchToFilter` path\n * as a real declaration, rather than a second hand-built predicate at the\n * call site. The target's own declaration always wins when present — this\n * is a fallback, not an override of a declared search.\n */\n readonly searchField?: string;\n /**\n * Gates the field-derivation walk (`useFilterFields`'s `categorizeField`\n * pass over every field of the resource). Defaults to `true`. A caller\n * whose UI isn't visible yet (e.g. `RelationPickerDialog` before it's\n * opened) should pass `false` to skip that work on every mount /\n * `fixedFilters`-identity change; the derivation recomputes in full the\n * next render where this flips back to `true`.\n */\n readonly enabled?: boolean;\n}\n\n/** Return shape of {@link useDialogFilterState}. */\nexport interface DialogFilterState {\n readonly group: FilterGroup;\n readonly setGroup: (next: FilterGroup) => void;\n readonly search: string;\n readonly setSearch: (next: string) => void;\n readonly page: number;\n readonly setPage: (page: number) => void;\n readonly available: readonly FilterFieldSpec[];\n readonly fixed: readonly ResolvedFixedFilter[];\n /** The single expression to hand the data provider. */\n readonly filter: ErasedExpression | undefined;\n /** Whether the target has a search for this user (no field left means no search box). */\n readonly searchEnabled: boolean;\n /** The read must not be sent: undecided access, or a filter naming a field the user may not view. */\n readonly blocked: boolean;\n readonly reset: () => void;\n}\n\n/**\n * Local (non-URL) filter state for the relation-picker's advanced-search\n * dialog.\n *\n * `useResourceList` cannot serve this: it unconditionally calls\n * `useResourceUrlState`, which throws without a `RouterAdapterProvider` and,\n * when one IS present, writes to `?<resource>.*`. A picker dialog mounted\n * inside a form for resource A, searching TARGET resource B, would then\n * write B's list state into the URL — clobbering a real B list page open on\n * another tab of the same app, and reading back whatever that page last\n * wrote on open. So this hook keeps `group`/`search`/`page` in plain\n * `useState` and reuses the SAME composition core's list controller uses\n * (`composeListFilter`) so the two never drift on what a filter means.\n *\n * No `pinned` slot: pinned controls are a list-PAGE affordance (permanent\n * screen real estate for a couple of always-visible facets); a modal has no\n * spare real estate to spend on them.\n */\nexport function useDialogFilterState(\n resource: string,\n options: UseDialogFilterStateOptions,\n): DialogFilterState {\n const app = useApp();\n const def = app.registry.get(resource);\n const { available, fixed } = useFilterFields(resource, {\n fixedFilters: options.fixedFilters,\n enabled: options.enabled,\n });\n\n const [group, setGroupState] = useState<FilterGroup>(EMPTY_FILTER_GROUP);\n const [search, setSearchState] = useState('');\n const [page, setPage] = useState(1);\n\n // Any change to WHAT is filtered invalidates the current page — the same\n // rule `useResourceList`'s URL-state controller applies to its own\n // group/search setters.\n const setGroup = useCallback((next: FilterGroup) => {\n setGroupState(next);\n setPage(1);\n }, []);\n\n const setSearch = useCallback((next: string) => {\n setSearchState(next);\n setPage(1);\n }, []);\n\n const reset = useCallback(() => {\n setGroupState(EMPTY_FILTER_GROUP);\n setSearchState('');\n setPage(1);\n }, []);\n\n // The target's own declaration always wins; `options.searchField` only\n // fills in when the target declares no search at all. Memoized so an\n // omitted declaration doesn't hand `composeListFilter`'s `filter` memo a\n // fresh `{ fields: [...] }` literal (and therefore a spurious recompute)\n // on every render — see `UseDialogFilterStateOptions.searchField`'s doc.\n const { searchField } = options;\n const access = useListAccess(resource);\n const accessReady = access.status === 'ready';\n const searchDecl = useMemo<SearchDecl | undefined>(() => {\n const declared = def?.views?.list?.search;\n const base =\n declared !== undefined\n ? declared\n : searchField === undefined\n ? undefined\n : { fields: [searchField] };\n return narrowSearchDecl(base, (field) => access.fieldVisible(field));\n }, [def, searchField, access]);\n const searchContext = useMemo(() => ({ resource, def }), [resource, def]);\n\n const filter = useMemo<ErasedExpression | undefined>(\n () => composeListFilter({ fixed, group, search, searchDecl, searchContext }),\n [fixed, group, search, searchDecl, searchContext],\n );\n\n // A read whose composed filter names a field the user may not view is forbidden (an author\n // predicate, or a function search's output); an undecided access sends nothing yet.\n const blocked = !accessReady || namesHiddenField(filter, (field) => access.fieldVisible(field));\n\n return useMemo<DialogFilterState>(\n () => ({\n searchEnabled: searchDecl !== undefined,\n blocked,\n group,\n setGroup,\n search,\n setSearch,\n page,\n setPage,\n available,\n fixed,\n filter,\n reset,\n }),\n [\n group,\n setGroup,\n search,\n setSearch,\n page,\n available,\n fixed,\n filter,\n reset,\n searchDecl,\n blocked,\n ],\n );\n}\n"],"mappings":";;;;;AAgFA,SAAgB,qBACd,UACA,SACmB;CAEnB,MAAM,OAAA,GADMA,cAAAA,OAAAA,CACE,CAAC,CAAC,SAAS,IAAI,QAAQ;CACrC,MAAM,EAAE,WAAW,WAAA,GAAUC,cAAAA,gBAAAA,CAAgB,UAAU;EACrD,cAAc,QAAQ;EACtB,SAAS,QAAQ;CACnB,CAAC;CAED,MAAM,CAAC,OAAO,kBAAA,GAAiBC,MAAAA,SAAAA,CAAsBC,aAAAA,kBAAkB;CACvE,MAAM,CAAC,QAAQ,mBAAA,GAAkBD,MAAAA,SAAAA,CAAS,EAAE;CAC5C,MAAM,CAAC,MAAM,YAAA,GAAWA,MAAAA,SAAAA,CAAS,CAAC;CAKlC,MAAM,YAAA,GAAWE,MAAAA,YAAAA,EAAa,SAAsB;EAClD,cAAc,IAAI;EAClB,QAAQ,CAAC;CACX,GAAG,CAAC,CAAC;CAEL,MAAM,aAAA,GAAYA,MAAAA,YAAAA,EAAa,SAAiB;EAC9C,eAAe,IAAI;EACnB,QAAQ,CAAC;CACX,GAAG,CAAC,CAAC;CAEL,MAAM,SAAA,GAAQA,MAAAA,YAAAA,OAAkB;EAC9B,cAAcD,aAAAA,kBAAkB;EAChC,eAAe,EAAE;EACjB,QAAQ,CAAC;CACX,GAAG,CAAC,CAAC;CAOL,MAAM,EAAE,gBAAgB;CACxB,MAAM,UAAA,GAASE,cAAAA,cAAAA,CAAc,QAAQ;CACrC,MAAM,cAAc,OAAO,WAAW;CACtC,MAAM,cAAA,GAAaC,MAAAA,QAAAA,OAAsC;EACvD,MAAM,WAAW,KAAK,OAAO,MAAM;EAOnC,OAAOC,sBAAAA,iBALL,aAAa,KAAA,IACT,WACA,gBAAgB,KAAA,IACd,KAAA,IACA,EAAE,QAAQ,CAAC,WAAW,EAAE,IACD,UAAU,OAAO,aAAa,KAAK,CAAC;CACrE,GAAG;EAAC;EAAK;EAAa;CAAM,CAAC;CAC7B,MAAM,iBAAA,GAAgBD,MAAAA,QAAAA,QAAe;EAAE;EAAU;CAAI,IAAI,CAAC,UAAU,GAAG,CAAC;CAExE,MAAM,UAAA,GAASA,MAAAA,QAAAA,QAAAA,GACPE,aAAAA,kBAAAA,CAAkB;EAAE;EAAO;EAAO;EAAQ;EAAY;CAAc,CAAC,GAC3E;EAAC;EAAO;EAAO;EAAQ;EAAY;CAAa,CAClD;CAIA,MAAM,UAAU,CAAC,eAAeC,sBAAAA,iBAAiB,SAAS,UAAU,OAAO,aAAa,KAAK,CAAC;CAE9F,QAAA,GAAOH,MAAAA,QAAAA,QACE;EACL,eAAe,eAAe,KAAA;EAC9B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,IACA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CACF;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-theme-DFlpXils.cjs","names":["twMerge","GithubSlugger","THEME_STORAGE_KEY","useState","useCallback"],"sources":["../src/lib/cn.ts","../src/markdown/anchors.ts","../src/layout/use-theme.ts"],"sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import GithubSlugger from 'github-slugger';\n\n/**\n * Anchor identity for documentation, in ONE place.\n *\n * Three things have to agree on what `#kak-ustroen-gtin` means: `rehype-slug`,\n * which stamps ids onto headings; the FAQ accordion, which is built from\n * headings but never goes through rehype; and whatever scrolls to a hash. When\n * they disagree the link simply does nothing — no error, no console warning,\n * the page just sits there — so the algorithm lives here and everyone imports it.\n *\n * `github-slugger` is not an extra dependency: `rehype-slug` uses it internally,\n * which is exactly why it is the right choice — matching its output by\n * reimplementing it would be a slow-motion bug in a language with case rules,\n * combining marks and a Cyrillic alphabet.\n */\n\n/** Prefix `rehype-sanitize` renames every id to. See `defaultSchema.clobberPrefix`. */\nexport const DOC_ID_PREFIX = 'user-content-';\n\n/**\n * Slug for a heading's text — the value that appears in a shareable `#hash`.\n *\n * A fresh slugger per call ON PURPOSE. `GithubSlugger` is stateful: it\n * remembers what it has emitted and disambiguates repeats as `foo-1`, `foo-2`.\n * That is right when walking one document top to bottom, and wrong here, where\n * the same heading must produce the same hash every time it is asked about.\n */\nexport function docSlug(text: string): string {\n return new GithubSlugger().slug(text);\n}\n\n/**\n * The current fragment, DECODED, without its `#`.\n *\n * `window.location.hash` hands back a percent-encoded string —\n * `#%D0%BF%D0%BE%D1%87%D0%B5%D0%BC%D1%83…` for `#почему…` — so every\n * comparison against a slug fails, silently, for exactly the alphabet this\n * project writes its documentation in. It works perfectly in English, which is\n * the worst possible way for it to be wrong.\n */\nexport function readDocHash(): string {\n if (typeof window === 'undefined') return '';\n const raw = window.location.hash.replace(/^#/, '');\n if (raw === '') return '';\n try {\n return decodeURIComponent(raw);\n } catch {\n // A malformed escape sequence is not worth throwing over.\n return raw;\n }\n}\n\n/**\n * Find the element a hash points at.\n *\n * Tries the prefixed id first, because a heading inside sanitized Markdown is\n * the common case, then the bare one for anchors we render ourselves (the FAQ\n * accordion, which is built from React and never passes through the sanitizer).\n */\nexport function findDocAnchor(hash: string, root: Document | Element = document): Element | null {\n let id = hash.replace(/^#/, '');\n if (id === '') return null;\n try {\n id = decodeURIComponent(id);\n } catch {\n /* keep the raw form */\n }\n\n /*\n `getElementById`, NOT `querySelector('#'+id)`.\n\n A fragment is user input — it arrives from the address bar — and `#a.b:c`\n is a perfectly ordinary thing to paste and an invalid CSS selector, so\n `querySelector` throws a `SyntaxError` on it. `CSS.escape` would fix that\n where it exists, and it does not exist in jsdom, so the crash would have\n been invisible to the unit suite and live only in production. Looking an id\n up by id involves no selector parsing at all.\n */\n const doc = root.ownerDocument ?? (root as Document);\n const pick = (candidate: string): Element | null => {\n const el = doc.getElementById(candidate);\n if (!el) return null;\n // Scoped lookups must not reach outside their root — the handbook searches\n // within the open article so a stale heading elsewhere cannot win.\n return root === doc || root.contains(el) ? el : null;\n };\n return pick(DOC_ID_PREFIX + id) ?? pick(id);\n}\n\n/**\n * Bring an element to the top of ITS OWN scroll container, and nothing else.\n *\n * `scrollIntoView` cannot be used here. It scrolls EVERY scrollable ancestor,\n * the document included — and the document does scroll in this shell, because\n * `SidebarInset` is `min-h-svh` and the app's own sidebar can exceed a short\n * viewport. Clicking an anchor therefore dragged the whole page up: the app\n * header and the handbook's own search box went off the top of the screen, and\n * the layout read as broken. Caught in a screenshot, not by any assertion.\n *\n * Walking to the nearest scrollable ancestor and moving only that keeps the\n * effect where the reader expects it — inside the column they are reading.\n * `scroll-margin-top` is honoured by hand, since we are no longer going through\n * the API that would apply it.\n */\nfunction placeInScroller(target: Element): void {\n let container = target.parentElement;\n while (container) {\n const style = getComputedStyle(container);\n if (\n /(auto|scroll)/.test(style.overflowY) &&\n container.scrollHeight > container.clientHeight + 1\n ) {\n break;\n }\n container = container.parentElement;\n }\n if (!container) {\n // Nothing between here and the document scrolls; the browser's own\n // behaviour is then exactly right.\n target.scrollIntoView({ block: 'start', behavior: 'auto' });\n return;\n }\n const margin = Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;\n const delta = target.getBoundingClientRect().top - container.getBoundingClientRect().top - margin;\n container.scrollTop += delta;\n}\n\n/**\n * Scroll to an anchor and KEEP it in view while the article settles.\n *\n * One `scrollIntoView` is not enough here. An article's height is not final\n * when its markup lands: a mermaid diagram renders asynchronously and a lazily\n * loaded image has no height until it arrives, and either one appearing ABOVE\n * the target pushes it back down — the reader watches the page scroll to the\n * right place and then drift away from it. Measured: the target ended 731px\n * down a 709px pane, i.e. just off the bottom.\n *\n * So the scroll is repeated whenever the container resizes, for a short window\n * and no longer. The window is a deliberate ceiling rather than a guess at how\n * long rendering takes: after it, the reader owns the scroll position, and\n * yanking it back because a late image loaded would be worse than being a few\n * pixels off.\n */\nexport function scrollToDocAnchor(\n hash: string,\n root: Element = document.body,\n settleMs = 1500,\n): () => void {\n let stopped = false;\n const go = () => {\n if (stopped) return;\n const target = findDocAnchor(hash, root);\n if (target) placeInScroller(target);\n };\n // Two frames: markdown → hast → DOM, then layout.\n const raf1 = requestAnimationFrame(() => requestAnimationFrame(go));\n\n const observer =\n typeof ResizeObserver === 'undefined'\n ? null\n : new ResizeObserver(() => requestAnimationFrame(go));\n observer?.observe(root);\n const timer = setTimeout(() => {\n stopped = true;\n observer?.disconnect();\n }, settleMs);\n\n return () => {\n stopped = true;\n cancelAnimationFrame(raf1);\n observer?.disconnect();\n clearTimeout(timer);\n };\n}\n\n/**\n * A shareable url for an anchor on the current page — READABLE.\n *\n * `new URL(…).toString()` percent-encodes the fragment, so a Russian heading\n * becomes forty characters of `%D0%BF%D0%BE…`. That is correct and unusable:\n * the entire point of this link is being pasted into a message where a person\n * reads it, and a wall of escapes tells them nothing about where it goes.\n * Browsers accept a literal fragment and encode it themselves on navigation.\n *\n * The slug is still escaped if it contains anything that would change how the\n * url PARSES — `#`, `?`, whitespace, or a stray `%`. `github-slugger` does not\n * emit those, but this is the one place where being wrong produces a link that\n * silently points somewhere else.\n */\nexport function docShareUrl(slug: string, href: string = window.location.href): string {\n const url = new URL(href);\n const safe = /[#?%\\s]/.test(slug) ? encodeURIComponent(slug) : slug;\n return `${url.origin}${url.pathname}${url.search}#${safe}`;\n}\n","'use client';\nimport { useCallback, useEffect, useState } from 'react';\nimport { THEME_STORAGE_KEY } from './theme-storage-key.js';\n\n/**\n * Re-exported here so most consumers only need one import path. A pre-paint\n * bootstrap is the one exception — `examples/next/app/layout.tsx` imports the\n * constant from the separate, hook-free `@burojs/ui-shadcn/theme-storage-key`\n * subpath instead; see `./theme-storage-key.js` for why a Server Component\n * cannot import it from here.\n */\nexport { THEME_STORAGE_KEY };\n\n/** What the user picked. `'system'` means \"no explicit choice\" — nothing is\n * persisted for it; its absence from storage IS the `system` state. */\nexport type ThemeChoice = 'light' | 'dark' | 'system';\n\n/** Every theme choice, in the order a control offers them. */\nexport const THEME_CHOICES: readonly ThemeChoice[] = ['light', 'dark', 'system'];\n\n/** What is actually painted. Never `'system'` — every reader (Toaster,\n * CodeMirror, Mermaid) only ever needs to know light or dark. */\nexport type ResolvedTheme = 'light' | 'dark';\n\nexport interface UseThemeResult {\n /** The persisted preference, including `'system'`. Only the theme\n * submenu (the one place with a three-way control) needs this. */\n choice: ThemeChoice;\n /** The resolved light/dark value, kept in sync with the `dark` class on\n * `<html>`. This is what every read-only consumer wants. */\n resolved: ResolvedTheme;\n /** Set a new choice: applies the `dark` class immediately and persists\n * (or, for `'system'`, clears) the preference. */\n setTheme: (next: ThemeChoice) => void;\n}\n\nfunction systemPrefersDark(): boolean {\n return (\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-color-scheme: dark)').matches\n );\n}\n\nfunction readChoice(): ThemeChoice {\n if (typeof window === 'undefined') return 'system';\n try {\n const saved = window.localStorage.getItem(THEME_STORAGE_KEY);\n if (saved === 'light' || saved === 'dark') return saved;\n } catch {\n // localStorage may be disabled — fall through to system\n }\n return 'system';\n}\n\nfunction resolveTheme(choice: ThemeChoice): ResolvedTheme {\n if (choice === 'light' || choice === 'dark') return choice;\n return systemPrefersDark() ? 'dark' : 'light';\n}\n\nfunction applyDomClass(resolved: ResolvedTheme): void {\n if (typeof document === 'undefined') return;\n document.documentElement.classList.toggle('dark', resolved === 'dark');\n}\n\nfunction persistChoice(choice: ThemeChoice): void {\n if (typeof window === 'undefined') return;\n try {\n if (choice === 'system') window.localStorage.removeItem(THEME_STORAGE_KEY);\n else window.localStorage.setItem(THEME_STORAGE_KEY, choice);\n } catch {\n // localStorage may be disabled — the class flip already applied.\n }\n}\n\ninterface ThemeState {\n choice: ThemeChoice;\n resolved: ResolvedTheme;\n}\n\n/**\n * Single source of truth for light/dark/system, replacing seven places that\n * each used to know about the theme independently (a bootstrap script, a\n * command-palette action, a submenu, an unused kit component, and three\n * MutationObserver-based readers).\n *\n * Read-only consumers (Toaster, CodeMirror, Mermaid) only need `resolved`\n * and don't call the public `setTheme` — they stay in sync via the\n * `<html class=\"dark\">` MutationObserver below, which fires regardless of\n * WHICH writer flipped it. That is deliberate: it's the same mechanism these\n * three already used individually before this hook existed, now shared\n * instead of duplicated, and it means a future writer doesn't need to know\n * about every reader. Every mounted instance — including these \"read-only\"\n * ones — still runs its OWN `onSystemChange` listener below and applies the\n * class itself on an OS change; see that listener for why re-reading storage\n * there (rather than trusting this instance's local `choice`) is required\n * for that to be safe with several instances mounted at once.\n *\n * `choice` (the three-way value, including `'system'`) is NOT synchronized\n * the same way — the `dark` class alone can't tell \"explicitly dark\" apart\n * from \"system, and the system happens to be dark\". The only consumer that\n * needs `choice` is the theme submenu, and Radix unmounts a closed dropdown's\n * content, so it re-reads storage fresh every time it opens; no live\n * cross-component sync of `choice` is needed in practice.\n *\n * NOT used by the bootstrap `<script>` in `app/layout.tsx` — see the comment\n * there for why a hook cannot do that job.\n */\nexport function useTheme(): UseThemeResult {\n // Safe default for the very first client render, corrected synchronously\n // by the effect below on mount — mirrors the pattern the three readers\n // used before migrating here (start light/false, fix it post-mount) so no\n // component newly grows a hydration-mismatch warning.\n const [state, setState] = useState<ThemeState>({ choice: 'system', resolved: 'light' });\n\n useEffect(() => {\n const choice = readChoice();\n setState({ choice, resolved: resolveTheme(choice) });\n\n const root = document.documentElement;\n const observer = new MutationObserver(() => {\n const resolved: ResolvedTheme = root.classList.contains('dark') ? 'dark' : 'light';\n setState((prev) => (prev.resolved === resolved ? prev : { ...prev, resolved }));\n });\n observer.observe(root, { attributes: true, attributeFilter: ['class'] });\n\n const mql =\n typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)')\n : undefined;\n const onSystemChange = (event: { matches: boolean }) => {\n // Re-read the stored choice instead of trusting this instance's own\n // `prev.choice`: `choice` is captured once at mount and never\n // resynchronised, but several `useTheme()` instances mount at once\n // (Toaster, CodeMirror, Mermaid, the admin layout, the theme submenu\n // while open). If a later explicit pick only updates the instance that\n // made it, every OTHER instance still believes `system` applies and\n // would repaint the whole app back to the OS value on the next\n // preference flip — silently overriding storage, which is why this\n // must not trust local state. Storage is already the single source of\n // truth (`setTheme('system')` clears the key), so a fresh read here is\n // correct in both directions: an explicit pick is respected, and\n // `system` is honoured even once the one instance that set it has\n // unmounted. Every other live instance's listener fires on the same OS\n // change and independently reaches the same conclusion, so several\n // instances may call `applyDomClass` with the same value for one\n // event — redundant, but harmless, and not worth coordinating.\n const choice = readChoice();\n if (choice !== 'system') return;\n const resolved: ResolvedTheme = event.matches ? 'dark' : 'light';\n applyDomClass(resolved);\n setState((prev) =>\n prev.choice === choice && prev.resolved === resolved ? prev : { choice, resolved },\n );\n };\n mql?.addEventListener('change', onSystemChange);\n\n return () => {\n observer.disconnect();\n mql?.removeEventListener('change', onSystemChange);\n };\n }, []);\n\n const setTheme = useCallback((next: ThemeChoice) => {\n const resolved = resolveTheme(next);\n applyDomClass(resolved);\n persistChoice(next);\n setState({ choice: next, resolved });\n }, []);\n\n return { choice: state.choice, resolved: state.resolved, setTheme };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-theme-DFlpXils.cjs","names":["twMerge","GithubSlugger","THEME_STORAGE_KEY","useState","useCallback"],"sources":["../src/lib/cn.ts","../src/markdown/anchors.ts","../src/layout/use-theme.ts"],"sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import GithubSlugger from 'github-slugger';\n\n/**\n * Anchor identity for documentation, in ONE place.\n *\n * Three things have to agree on what `#kak-ustroen-gtin` means: `rehype-slug`,\n * which stamps ids onto headings; the FAQ accordion, which is built from\n * headings but never goes through rehype; and whatever scrolls to a hash. When\n * they disagree the link simply does nothing — no error, no console warning,\n * the page just sits there — so the algorithm lives here and everyone imports it.\n *\n * `github-slugger` is not an extra dependency: `rehype-slug` uses it internally,\n * which is exactly why it is the right choice — matching its output by\n * reimplementing it would be a slow-motion bug in a language with case rules,\n * combining marks and a Cyrillic alphabet.\n */\n\n/** Prefix `rehype-sanitize` renames every id to. See `defaultSchema.clobberPrefix`. */\nexport const DOC_ID_PREFIX = 'user-content-';\n\n/**\n * Slug for a heading's text — the value that appears in a shareable `#hash`.\n *\n * A fresh slugger per call ON PURPOSE. `GithubSlugger` is stateful: it\n * remembers what it has emitted and disambiguates repeats as `foo-1`, `foo-2`.\n * That is right when walking one document top to bottom, and wrong here, where\n * the same heading must produce the same hash every time it is asked about.\n */\nexport function docSlug(text: string): string {\n return new GithubSlugger().slug(text);\n}\n\n/**\n * The current fragment, DECODED, without its `#`.\n *\n * `window.location.hash` hands back a percent-encoded string —\n * `#%D0%BF%D0%BE%D1%87%D0%B5%D0%BC%D1%83…` for a non-ASCII slug — so every\n * comparison against a slug fails, silently, for every non-ASCII alphabet. It works perfectly in ASCII-only slugs, which is\n * the worst possible way for it to be wrong.\n */\nexport function readDocHash(): string {\n if (typeof window === 'undefined') return '';\n const raw = window.location.hash.replace(/^#/, '');\n if (raw === '') return '';\n try {\n return decodeURIComponent(raw);\n } catch {\n // A malformed escape sequence is not worth throwing over.\n return raw;\n }\n}\n\n/**\n * Find the element a hash points at.\n *\n * Tries the prefixed id first, because a heading inside sanitized Markdown is\n * the common case, then the bare one for anchors we render ourselves (the FAQ\n * accordion, which is built from React and never passes through the sanitizer).\n */\nexport function findDocAnchor(hash: string, root: Document | Element = document): Element | null {\n let id = hash.replace(/^#/, '');\n if (id === '') return null;\n try {\n id = decodeURIComponent(id);\n } catch {\n /* keep the raw form */\n }\n\n /*\n `getElementById`, NOT `querySelector('#'+id)`.\n\n A fragment is user input — it arrives from the address bar — and `#a.b:c`\n is a perfectly ordinary thing to paste and an invalid CSS selector, so\n `querySelector` throws a `SyntaxError` on it. `CSS.escape` would fix that\n where it exists, and it does not exist in jsdom, so the crash would have\n been invisible to the unit suite and live only in production. Looking an id\n up by id involves no selector parsing at all.\n */\n const doc = root.ownerDocument ?? (root as Document);\n const pick = (candidate: string): Element | null => {\n const el = doc.getElementById(candidate);\n if (!el) return null;\n // Scoped lookups must not reach outside their root — the handbook searches\n // within the open article so a stale heading elsewhere cannot win.\n return root === doc || root.contains(el) ? el : null;\n };\n return pick(DOC_ID_PREFIX + id) ?? pick(id);\n}\n\n/**\n * Bring an element to the top of ITS OWN scroll container, and nothing else.\n *\n * `scrollIntoView` cannot be used here. It scrolls EVERY scrollable ancestor,\n * the document included — and the document does scroll in this shell, because\n * `SidebarInset` is `min-h-svh` and the app's own sidebar can exceed a short\n * viewport. Clicking an anchor therefore dragged the whole page up: the app\n * header and the handbook's own search box went off the top of the screen, and\n * the layout read as broken. Caught in a screenshot, not by any assertion.\n *\n * Walking to the nearest scrollable ancestor and moving only that keeps the\n * effect where the reader expects it — inside the column they are reading.\n * `scroll-margin-top` is honoured by hand, since we are no longer going through\n * the API that would apply it.\n */\nfunction placeInScroller(target: Element): void {\n let container = target.parentElement;\n while (container) {\n const style = getComputedStyle(container);\n if (\n /(auto|scroll)/.test(style.overflowY) &&\n container.scrollHeight > container.clientHeight + 1\n ) {\n break;\n }\n container = container.parentElement;\n }\n if (!container) {\n // Nothing between here and the document scrolls; the browser's own\n // behaviour is then exactly right.\n target.scrollIntoView({ block: 'start', behavior: 'auto' });\n return;\n }\n const margin = Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;\n const delta = target.getBoundingClientRect().top - container.getBoundingClientRect().top - margin;\n container.scrollTop += delta;\n}\n\n/**\n * Scroll to an anchor and KEEP it in view while the article settles.\n *\n * One `scrollIntoView` is not enough here. An article's height is not final\n * when its markup lands: a mermaid diagram renders asynchronously and a lazily\n * loaded image has no height until it arrives, and either one appearing ABOVE\n * the target pushes it back down — the reader watches the page scroll to the\n * right place and then drift away from it. Measured: the target ended 731px\n * down a 709px pane, i.e. just off the bottom.\n *\n * So the scroll is repeated whenever the container resizes, for a short window\n * and no longer. The window is a deliberate ceiling rather than a guess at how\n * long rendering takes: after it, the reader owns the scroll position, and\n * yanking it back because a late image loaded would be worse than being a few\n * pixels off.\n */\nexport function scrollToDocAnchor(\n hash: string,\n root: Element = document.body,\n settleMs = 1500,\n): () => void {\n let stopped = false;\n const go = () => {\n if (stopped) return;\n const target = findDocAnchor(hash, root);\n if (target) placeInScroller(target);\n };\n // Two frames: markdown → hast → DOM, then layout.\n const raf1 = requestAnimationFrame(() => requestAnimationFrame(go));\n\n const observer =\n typeof ResizeObserver === 'undefined'\n ? null\n : new ResizeObserver(() => requestAnimationFrame(go));\n observer?.observe(root);\n const timer = setTimeout(() => {\n stopped = true;\n observer?.disconnect();\n }, settleMs);\n\n return () => {\n stopped = true;\n cancelAnimationFrame(raf1);\n observer?.disconnect();\n clearTimeout(timer);\n };\n}\n\n/**\n * A shareable url for an anchor on the current page — READABLE.\n *\n * `new URL(…).toString()` percent-encodes the fragment, so a Russian heading\n * becomes forty characters of `%D0%BF%D0%BE…`. That is correct and unusable:\n * the entire point of this link is being pasted into a message where a person\n * reads it, and a wall of escapes tells them nothing about where it goes.\n * Browsers accept a literal fragment and encode it themselves on navigation.\n *\n * The slug is still escaped if it contains anything that would change how the\n * url PARSES — `#`, `?`, whitespace, or a stray `%`. `github-slugger` does not\n * emit those, but this is the one place where being wrong produces a link that\n * silently points somewhere else.\n */\nexport function docShareUrl(slug: string, href: string = window.location.href): string {\n const url = new URL(href);\n const safe = /[#?%\\s]/.test(slug) ? encodeURIComponent(slug) : slug;\n return `${url.origin}${url.pathname}${url.search}#${safe}`;\n}\n","'use client';\nimport { useCallback, useEffect, useState } from 'react';\nimport { THEME_STORAGE_KEY } from './theme-storage-key.js';\n\n/**\n * Re-exported here so most consumers only need one import path. A pre-paint\n * bootstrap is the one exception — `examples/next/app/layout.tsx` imports the\n * constant from the separate, hook-free `@burojs/ui-shadcn/theme-storage-key`\n * subpath instead; see `./theme-storage-key.js` for why a Server Component\n * cannot import it from here.\n */\nexport { THEME_STORAGE_KEY };\n\n/** What the user picked. `'system'` means \"no explicit choice\" — nothing is\n * persisted for it; its absence from storage IS the `system` state. */\nexport type ThemeChoice = 'light' | 'dark' | 'system';\n\n/** Every theme choice, in the order a control offers them. */\nexport const THEME_CHOICES: readonly ThemeChoice[] = ['light', 'dark', 'system'];\n\n/** What is actually painted. Never `'system'` — every reader (Toaster,\n * CodeMirror, Mermaid) only ever needs to know light or dark. */\nexport type ResolvedTheme = 'light' | 'dark';\n\nexport interface UseThemeResult {\n /** The persisted preference, including `'system'`. Only the theme\n * submenu (the one place with a three-way control) needs this. */\n choice: ThemeChoice;\n /** The resolved light/dark value, kept in sync with the `dark` class on\n * `<html>`. This is what every read-only consumer wants. */\n resolved: ResolvedTheme;\n /** Set a new choice: applies the `dark` class immediately and persists\n * (or, for `'system'`, clears) the preference. */\n setTheme: (next: ThemeChoice) => void;\n}\n\nfunction systemPrefersDark(): boolean {\n return (\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-color-scheme: dark)').matches\n );\n}\n\nfunction readChoice(): ThemeChoice {\n if (typeof window === 'undefined') return 'system';\n try {\n const saved = window.localStorage.getItem(THEME_STORAGE_KEY);\n if (saved === 'light' || saved === 'dark') return saved;\n } catch {\n // localStorage may be disabled — fall through to system\n }\n return 'system';\n}\n\nfunction resolveTheme(choice: ThemeChoice): ResolvedTheme {\n if (choice === 'light' || choice === 'dark') return choice;\n return systemPrefersDark() ? 'dark' : 'light';\n}\n\nfunction applyDomClass(resolved: ResolvedTheme): void {\n if (typeof document === 'undefined') return;\n document.documentElement.classList.toggle('dark', resolved === 'dark');\n}\n\nfunction persistChoice(choice: ThemeChoice): void {\n if (typeof window === 'undefined') return;\n try {\n if (choice === 'system') window.localStorage.removeItem(THEME_STORAGE_KEY);\n else window.localStorage.setItem(THEME_STORAGE_KEY, choice);\n } catch {\n // localStorage may be disabled — the class flip already applied.\n }\n}\n\ninterface ThemeState {\n choice: ThemeChoice;\n resolved: ResolvedTheme;\n}\n\n/**\n * Single source of truth for light/dark/system, replacing seven places that\n * each used to know about the theme independently (a bootstrap script, a\n * command-palette action, a submenu, an unused kit component, and three\n * MutationObserver-based readers).\n *\n * Read-only consumers (Toaster, CodeMirror, Mermaid) only need `resolved`\n * and don't call the public `setTheme` — they stay in sync via the\n * `<html class=\"dark\">` MutationObserver below, which fires regardless of\n * WHICH writer flipped it. That is deliberate: it's the same mechanism these\n * three already used individually before this hook existed, now shared\n * instead of duplicated, and it means a future writer doesn't need to know\n * about every reader. Every mounted instance — including these \"read-only\"\n * ones — still runs its OWN `onSystemChange` listener below and applies the\n * class itself on an OS change; see that listener for why re-reading storage\n * there (rather than trusting this instance's local `choice`) is required\n * for that to be safe with several instances mounted at once.\n *\n * `choice` (the three-way value, including `'system'`) is NOT synchronized\n * the same way — the `dark` class alone can't tell \"explicitly dark\" apart\n * from \"system, and the system happens to be dark\". The only consumer that\n * needs `choice` is the theme submenu, and Radix unmounts a closed dropdown's\n * content, so it re-reads storage fresh every time it opens; no live\n * cross-component sync of `choice` is needed in practice.\n *\n * NOT used by the bootstrap `<script>` in `app/layout.tsx` — see the comment\n * there for why a hook cannot do that job.\n */\nexport function useTheme(): UseThemeResult {\n // Safe default for the very first client render, corrected synchronously\n // by the effect below on mount — mirrors the pattern the three readers\n // used before migrating here (start light/false, fix it post-mount) so no\n // component newly grows a hydration-mismatch warning.\n const [state, setState] = useState<ThemeState>({ choice: 'system', resolved: 'light' });\n\n useEffect(() => {\n const choice = readChoice();\n setState({ choice, resolved: resolveTheme(choice) });\n\n const root = document.documentElement;\n const observer = new MutationObserver(() => {\n const resolved: ResolvedTheme = root.classList.contains('dark') ? 'dark' : 'light';\n setState((prev) => (prev.resolved === resolved ? prev : { ...prev, resolved }));\n });\n observer.observe(root, { attributes: true, attributeFilter: ['class'] });\n\n const mql =\n typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)')\n : undefined;\n const onSystemChange = (event: { matches: boolean }) => {\n // Re-read the stored choice instead of trusting this instance's own\n // `prev.choice`: `choice` is captured once at mount and never\n // resynchronised, but several `useTheme()` instances mount at once\n // (Toaster, CodeMirror, Mermaid, the admin layout, the theme submenu\n // while open). If a later explicit pick only updates the instance that\n // made it, every OTHER instance still believes `system` applies and\n // would repaint the whole app back to the OS value on the next\n // preference flip — silently overriding storage, which is why this\n // must not trust local state. Storage is already the single source of\n // truth (`setTheme('system')` clears the key), so a fresh read here is\n // correct in both directions: an explicit pick is respected, and\n // `system` is honoured even once the one instance that set it has\n // unmounted. Every other live instance's listener fires on the same OS\n // change and independently reaches the same conclusion, so several\n // instances may call `applyDomClass` with the same value for one\n // event — redundant, but harmless, and not worth coordinating.\n const choice = readChoice();\n if (choice !== 'system') return;\n const resolved: ResolvedTheme = event.matches ? 'dark' : 'light';\n applyDomClass(resolved);\n setState((prev) =>\n prev.choice === choice && prev.resolved === resolved ? prev : { choice, resolved },\n );\n };\n mql?.addEventListener('change', onSystemChange);\n\n return () => {\n observer.disconnect();\n mql?.removeEventListener('change', onSystemChange);\n };\n }, []);\n\n const setTheme = useCallback((next: ThemeChoice) => {\n const resolved = resolveTheme(next);\n applyDomClass(resolved);\n persistChoice(next);\n setState({ choice: next, resolved });\n }, []);\n\n return { choice: state.choice, resolved: state.resolved, setTheme };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-theme-dnE3Od3I.js","names":[],"sources":["../src/lib/cn.ts","../src/markdown/anchors.ts","../src/layout/use-theme.ts"],"sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import GithubSlugger from 'github-slugger';\n\n/**\n * Anchor identity for documentation, in ONE place.\n *\n * Three things have to agree on what `#kak-ustroen-gtin` means: `rehype-slug`,\n * which stamps ids onto headings; the FAQ accordion, which is built from\n * headings but never goes through rehype; and whatever scrolls to a hash. When\n * they disagree the link simply does nothing — no error, no console warning,\n * the page just sits there — so the algorithm lives here and everyone imports it.\n *\n * `github-slugger` is not an extra dependency: `rehype-slug` uses it internally,\n * which is exactly why it is the right choice — matching its output by\n * reimplementing it would be a slow-motion bug in a language with case rules,\n * combining marks and a Cyrillic alphabet.\n */\n\n/** Prefix `rehype-sanitize` renames every id to. See `defaultSchema.clobberPrefix`. */\nexport const DOC_ID_PREFIX = 'user-content-';\n\n/**\n * Slug for a heading's text — the value that appears in a shareable `#hash`.\n *\n * A fresh slugger per call ON PURPOSE. `GithubSlugger` is stateful: it\n * remembers what it has emitted and disambiguates repeats as `foo-1`, `foo-2`.\n * That is right when walking one document top to bottom, and wrong here, where\n * the same heading must produce the same hash every time it is asked about.\n */\nexport function docSlug(text: string): string {\n return new GithubSlugger().slug(text);\n}\n\n/**\n * The current fragment, DECODED, without its `#`.\n *\n * `window.location.hash` hands back a percent-encoded string —\n * `#%D0%BF%D0%BE%D1%87%D0%B5%D0%BC%D1%83…` for `#почему…` — so every\n * comparison against a slug fails, silently, for exactly the alphabet this\n * project writes its documentation in. It works perfectly in English, which is\n * the worst possible way for it to be wrong.\n */\nexport function readDocHash(): string {\n if (typeof window === 'undefined') return '';\n const raw = window.location.hash.replace(/^#/, '');\n if (raw === '') return '';\n try {\n return decodeURIComponent(raw);\n } catch {\n // A malformed escape sequence is not worth throwing over.\n return raw;\n }\n}\n\n/**\n * Find the element a hash points at.\n *\n * Tries the prefixed id first, because a heading inside sanitized Markdown is\n * the common case, then the bare one for anchors we render ourselves (the FAQ\n * accordion, which is built from React and never passes through the sanitizer).\n */\nexport function findDocAnchor(hash: string, root: Document | Element = document): Element | null {\n let id = hash.replace(/^#/, '');\n if (id === '') return null;\n try {\n id = decodeURIComponent(id);\n } catch {\n /* keep the raw form */\n }\n\n /*\n `getElementById`, NOT `querySelector('#'+id)`.\n\n A fragment is user input — it arrives from the address bar — and `#a.b:c`\n is a perfectly ordinary thing to paste and an invalid CSS selector, so\n `querySelector` throws a `SyntaxError` on it. `CSS.escape` would fix that\n where it exists, and it does not exist in jsdom, so the crash would have\n been invisible to the unit suite and live only in production. Looking an id\n up by id involves no selector parsing at all.\n */\n const doc = root.ownerDocument ?? (root as Document);\n const pick = (candidate: string): Element | null => {\n const el = doc.getElementById(candidate);\n if (!el) return null;\n // Scoped lookups must not reach outside their root — the handbook searches\n // within the open article so a stale heading elsewhere cannot win.\n return root === doc || root.contains(el) ? el : null;\n };\n return pick(DOC_ID_PREFIX + id) ?? pick(id);\n}\n\n/**\n * Bring an element to the top of ITS OWN scroll container, and nothing else.\n *\n * `scrollIntoView` cannot be used here. It scrolls EVERY scrollable ancestor,\n * the document included — and the document does scroll in this shell, because\n * `SidebarInset` is `min-h-svh` and the app's own sidebar can exceed a short\n * viewport. Clicking an anchor therefore dragged the whole page up: the app\n * header and the handbook's own search box went off the top of the screen, and\n * the layout read as broken. Caught in a screenshot, not by any assertion.\n *\n * Walking to the nearest scrollable ancestor and moving only that keeps the\n * effect where the reader expects it — inside the column they are reading.\n * `scroll-margin-top` is honoured by hand, since we are no longer going through\n * the API that would apply it.\n */\nfunction placeInScroller(target: Element): void {\n let container = target.parentElement;\n while (container) {\n const style = getComputedStyle(container);\n if (\n /(auto|scroll)/.test(style.overflowY) &&\n container.scrollHeight > container.clientHeight + 1\n ) {\n break;\n }\n container = container.parentElement;\n }\n if (!container) {\n // Nothing between here and the document scrolls; the browser's own\n // behaviour is then exactly right.\n target.scrollIntoView({ block: 'start', behavior: 'auto' });\n return;\n }\n const margin = Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;\n const delta = target.getBoundingClientRect().top - container.getBoundingClientRect().top - margin;\n container.scrollTop += delta;\n}\n\n/**\n * Scroll to an anchor and KEEP it in view while the article settles.\n *\n * One `scrollIntoView` is not enough here. An article's height is not final\n * when its markup lands: a mermaid diagram renders asynchronously and a lazily\n * loaded image has no height until it arrives, and either one appearing ABOVE\n * the target pushes it back down — the reader watches the page scroll to the\n * right place and then drift away from it. Measured: the target ended 731px\n * down a 709px pane, i.e. just off the bottom.\n *\n * So the scroll is repeated whenever the container resizes, for a short window\n * and no longer. The window is a deliberate ceiling rather than a guess at how\n * long rendering takes: after it, the reader owns the scroll position, and\n * yanking it back because a late image loaded would be worse than being a few\n * pixels off.\n */\nexport function scrollToDocAnchor(\n hash: string,\n root: Element = document.body,\n settleMs = 1500,\n): () => void {\n let stopped = false;\n const go = () => {\n if (stopped) return;\n const target = findDocAnchor(hash, root);\n if (target) placeInScroller(target);\n };\n // Two frames: markdown → hast → DOM, then layout.\n const raf1 = requestAnimationFrame(() => requestAnimationFrame(go));\n\n const observer =\n typeof ResizeObserver === 'undefined'\n ? null\n : new ResizeObserver(() => requestAnimationFrame(go));\n observer?.observe(root);\n const timer = setTimeout(() => {\n stopped = true;\n observer?.disconnect();\n }, settleMs);\n\n return () => {\n stopped = true;\n cancelAnimationFrame(raf1);\n observer?.disconnect();\n clearTimeout(timer);\n };\n}\n\n/**\n * A shareable url for an anchor on the current page — READABLE.\n *\n * `new URL(…).toString()` percent-encodes the fragment, so a Russian heading\n * becomes forty characters of `%D0%BF%D0%BE…`. That is correct and unusable:\n * the entire point of this link is being pasted into a message where a person\n * reads it, and a wall of escapes tells them nothing about where it goes.\n * Browsers accept a literal fragment and encode it themselves on navigation.\n *\n * The slug is still escaped if it contains anything that would change how the\n * url PARSES — `#`, `?`, whitespace, or a stray `%`. `github-slugger` does not\n * emit those, but this is the one place where being wrong produces a link that\n * silently points somewhere else.\n */\nexport function docShareUrl(slug: string, href: string = window.location.href): string {\n const url = new URL(href);\n const safe = /[#?%\\s]/.test(slug) ? encodeURIComponent(slug) : slug;\n return `${url.origin}${url.pathname}${url.search}#${safe}`;\n}\n","'use client';\nimport { useCallback, useEffect, useState } from 'react';\nimport { THEME_STORAGE_KEY } from './theme-storage-key.js';\n\n/**\n * Re-exported here so most consumers only need one import path. A pre-paint\n * bootstrap is the one exception — `examples/next/app/layout.tsx` imports the\n * constant from the separate, hook-free `@burojs/ui-shadcn/theme-storage-key`\n * subpath instead; see `./theme-storage-key.js` for why a Server Component\n * cannot import it from here.\n */\nexport { THEME_STORAGE_KEY };\n\n/** What the user picked. `'system'` means \"no explicit choice\" — nothing is\n * persisted for it; its absence from storage IS the `system` state. */\nexport type ThemeChoice = 'light' | 'dark' | 'system';\n\n/** Every theme choice, in the order a control offers them. */\nexport const THEME_CHOICES: readonly ThemeChoice[] = ['light', 'dark', 'system'];\n\n/** What is actually painted. Never `'system'` — every reader (Toaster,\n * CodeMirror, Mermaid) only ever needs to know light or dark. */\nexport type ResolvedTheme = 'light' | 'dark';\n\nexport interface UseThemeResult {\n /** The persisted preference, including `'system'`. Only the theme\n * submenu (the one place with a three-way control) needs this. */\n choice: ThemeChoice;\n /** The resolved light/dark value, kept in sync with the `dark` class on\n * `<html>`. This is what every read-only consumer wants. */\n resolved: ResolvedTheme;\n /** Set a new choice: applies the `dark` class immediately and persists\n * (or, for `'system'`, clears) the preference. */\n setTheme: (next: ThemeChoice) => void;\n}\n\nfunction systemPrefersDark(): boolean {\n return (\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-color-scheme: dark)').matches\n );\n}\n\nfunction readChoice(): ThemeChoice {\n if (typeof window === 'undefined') return 'system';\n try {\n const saved = window.localStorage.getItem(THEME_STORAGE_KEY);\n if (saved === 'light' || saved === 'dark') return saved;\n } catch {\n // localStorage may be disabled — fall through to system\n }\n return 'system';\n}\n\nfunction resolveTheme(choice: ThemeChoice): ResolvedTheme {\n if (choice === 'light' || choice === 'dark') return choice;\n return systemPrefersDark() ? 'dark' : 'light';\n}\n\nfunction applyDomClass(resolved: ResolvedTheme): void {\n if (typeof document === 'undefined') return;\n document.documentElement.classList.toggle('dark', resolved === 'dark');\n}\n\nfunction persistChoice(choice: ThemeChoice): void {\n if (typeof window === 'undefined') return;\n try {\n if (choice === 'system') window.localStorage.removeItem(THEME_STORAGE_KEY);\n else window.localStorage.setItem(THEME_STORAGE_KEY, choice);\n } catch {\n // localStorage may be disabled — the class flip already applied.\n }\n}\n\ninterface ThemeState {\n choice: ThemeChoice;\n resolved: ResolvedTheme;\n}\n\n/**\n * Single source of truth for light/dark/system, replacing seven places that\n * each used to know about the theme independently (a bootstrap script, a\n * command-palette action, a submenu, an unused kit component, and three\n * MutationObserver-based readers).\n *\n * Read-only consumers (Toaster, CodeMirror, Mermaid) only need `resolved`\n * and don't call the public `setTheme` — they stay in sync via the\n * `<html class=\"dark\">` MutationObserver below, which fires regardless of\n * WHICH writer flipped it. That is deliberate: it's the same mechanism these\n * three already used individually before this hook existed, now shared\n * instead of duplicated, and it means a future writer doesn't need to know\n * about every reader. Every mounted instance — including these \"read-only\"\n * ones — still runs its OWN `onSystemChange` listener below and applies the\n * class itself on an OS change; see that listener for why re-reading storage\n * there (rather than trusting this instance's local `choice`) is required\n * for that to be safe with several instances mounted at once.\n *\n * `choice` (the three-way value, including `'system'`) is NOT synchronized\n * the same way — the `dark` class alone can't tell \"explicitly dark\" apart\n * from \"system, and the system happens to be dark\". The only consumer that\n * needs `choice` is the theme submenu, and Radix unmounts a closed dropdown's\n * content, so it re-reads storage fresh every time it opens; no live\n * cross-component sync of `choice` is needed in practice.\n *\n * NOT used by the bootstrap `<script>` in `app/layout.tsx` — see the comment\n * there for why a hook cannot do that job.\n */\nexport function useTheme(): UseThemeResult {\n // Safe default for the very first client render, corrected synchronously\n // by the effect below on mount — mirrors the pattern the three readers\n // used before migrating here (start light/false, fix it post-mount) so no\n // component newly grows a hydration-mismatch warning.\n const [state, setState] = useState<ThemeState>({ choice: 'system', resolved: 'light' });\n\n useEffect(() => {\n const choice = readChoice();\n setState({ choice, resolved: resolveTheme(choice) });\n\n const root = document.documentElement;\n const observer = new MutationObserver(() => {\n const resolved: ResolvedTheme = root.classList.contains('dark') ? 'dark' : 'light';\n setState((prev) => (prev.resolved === resolved ? prev : { ...prev, resolved }));\n });\n observer.observe(root, { attributes: true, attributeFilter: ['class'] });\n\n const mql =\n typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)')\n : undefined;\n const onSystemChange = (event: { matches: boolean }) => {\n // Re-read the stored choice instead of trusting this instance's own\n // `prev.choice`: `choice` is captured once at mount and never\n // resynchronised, but several `useTheme()` instances mount at once\n // (Toaster, CodeMirror, Mermaid, the admin layout, the theme submenu\n // while open). If a later explicit pick only updates the instance that\n // made it, every OTHER instance still believes `system` applies and\n // would repaint the whole app back to the OS value on the next\n // preference flip — silently overriding storage, which is why this\n // must not trust local state. Storage is already the single source of\n // truth (`setTheme('system')` clears the key), so a fresh read here is\n // correct in both directions: an explicit pick is respected, and\n // `system` is honoured even once the one instance that set it has\n // unmounted. Every other live instance's listener fires on the same OS\n // change and independently reaches the same conclusion, so several\n // instances may call `applyDomClass` with the same value for one\n // event — redundant, but harmless, and not worth coordinating.\n const choice = readChoice();\n if (choice !== 'system') return;\n const resolved: ResolvedTheme = event.matches ? 'dark' : 'light';\n applyDomClass(resolved);\n setState((prev) =>\n prev.choice === choice && prev.resolved === resolved ? prev : { choice, resolved },\n );\n };\n mql?.addEventListener('change', onSystemChange);\n\n return () => {\n observer.disconnect();\n mql?.removeEventListener('change', onSystemChange);\n };\n }, []);\n\n const setTheme = useCallback((next: ThemeChoice) => {\n const resolved = resolveTheme(next);\n applyDomClass(resolved);\n persistChoice(next);\n setState({ choice: next, resolved });\n }, []);\n\n return { choice: state.choice, resolved: state.resolved, setTheme };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-theme-dnE3Od3I.js","names":[],"sources":["../src/lib/cn.ts","../src/markdown/anchors.ts","../src/layout/use-theme.ts"],"sourcesContent":["import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import GithubSlugger from 'github-slugger';\n\n/**\n * Anchor identity for documentation, in ONE place.\n *\n * Three things have to agree on what `#kak-ustroen-gtin` means: `rehype-slug`,\n * which stamps ids onto headings; the FAQ accordion, which is built from\n * headings but never goes through rehype; and whatever scrolls to a hash. When\n * they disagree the link simply does nothing — no error, no console warning,\n * the page just sits there — so the algorithm lives here and everyone imports it.\n *\n * `github-slugger` is not an extra dependency: `rehype-slug` uses it internally,\n * which is exactly why it is the right choice — matching its output by\n * reimplementing it would be a slow-motion bug in a language with case rules,\n * combining marks and a Cyrillic alphabet.\n */\n\n/** Prefix `rehype-sanitize` renames every id to. See `defaultSchema.clobberPrefix`. */\nexport const DOC_ID_PREFIX = 'user-content-';\n\n/**\n * Slug for a heading's text — the value that appears in a shareable `#hash`.\n *\n * A fresh slugger per call ON PURPOSE. `GithubSlugger` is stateful: it\n * remembers what it has emitted and disambiguates repeats as `foo-1`, `foo-2`.\n * That is right when walking one document top to bottom, and wrong here, where\n * the same heading must produce the same hash every time it is asked about.\n */\nexport function docSlug(text: string): string {\n return new GithubSlugger().slug(text);\n}\n\n/**\n * The current fragment, DECODED, without its `#`.\n *\n * `window.location.hash` hands back a percent-encoded string —\n * `#%D0%BF%D0%BE%D1%87%D0%B5%D0%BC%D1%83…` for a non-ASCII slug — so every\n * comparison against a slug fails, silently, for every non-ASCII alphabet. It works perfectly in ASCII-only slugs, which is\n * the worst possible way for it to be wrong.\n */\nexport function readDocHash(): string {\n if (typeof window === 'undefined') return '';\n const raw = window.location.hash.replace(/^#/, '');\n if (raw === '') return '';\n try {\n return decodeURIComponent(raw);\n } catch {\n // A malformed escape sequence is not worth throwing over.\n return raw;\n }\n}\n\n/**\n * Find the element a hash points at.\n *\n * Tries the prefixed id first, because a heading inside sanitized Markdown is\n * the common case, then the bare one for anchors we render ourselves (the FAQ\n * accordion, which is built from React and never passes through the sanitizer).\n */\nexport function findDocAnchor(hash: string, root: Document | Element = document): Element | null {\n let id = hash.replace(/^#/, '');\n if (id === '') return null;\n try {\n id = decodeURIComponent(id);\n } catch {\n /* keep the raw form */\n }\n\n /*\n `getElementById`, NOT `querySelector('#'+id)`.\n\n A fragment is user input — it arrives from the address bar — and `#a.b:c`\n is a perfectly ordinary thing to paste and an invalid CSS selector, so\n `querySelector` throws a `SyntaxError` on it. `CSS.escape` would fix that\n where it exists, and it does not exist in jsdom, so the crash would have\n been invisible to the unit suite and live only in production. Looking an id\n up by id involves no selector parsing at all.\n */\n const doc = root.ownerDocument ?? (root as Document);\n const pick = (candidate: string): Element | null => {\n const el = doc.getElementById(candidate);\n if (!el) return null;\n // Scoped lookups must not reach outside their root — the handbook searches\n // within the open article so a stale heading elsewhere cannot win.\n return root === doc || root.contains(el) ? el : null;\n };\n return pick(DOC_ID_PREFIX + id) ?? pick(id);\n}\n\n/**\n * Bring an element to the top of ITS OWN scroll container, and nothing else.\n *\n * `scrollIntoView` cannot be used here. It scrolls EVERY scrollable ancestor,\n * the document included — and the document does scroll in this shell, because\n * `SidebarInset` is `min-h-svh` and the app's own sidebar can exceed a short\n * viewport. Clicking an anchor therefore dragged the whole page up: the app\n * header and the handbook's own search box went off the top of the screen, and\n * the layout read as broken. Caught in a screenshot, not by any assertion.\n *\n * Walking to the nearest scrollable ancestor and moving only that keeps the\n * effect where the reader expects it — inside the column they are reading.\n * `scroll-margin-top` is honoured by hand, since we are no longer going through\n * the API that would apply it.\n */\nfunction placeInScroller(target: Element): void {\n let container = target.parentElement;\n while (container) {\n const style = getComputedStyle(container);\n if (\n /(auto|scroll)/.test(style.overflowY) &&\n container.scrollHeight > container.clientHeight + 1\n ) {\n break;\n }\n container = container.parentElement;\n }\n if (!container) {\n // Nothing between here and the document scrolls; the browser's own\n // behaviour is then exactly right.\n target.scrollIntoView({ block: 'start', behavior: 'auto' });\n return;\n }\n const margin = Number.parseFloat(getComputedStyle(target).scrollMarginTop) || 0;\n const delta = target.getBoundingClientRect().top - container.getBoundingClientRect().top - margin;\n container.scrollTop += delta;\n}\n\n/**\n * Scroll to an anchor and KEEP it in view while the article settles.\n *\n * One `scrollIntoView` is not enough here. An article's height is not final\n * when its markup lands: a mermaid diagram renders asynchronously and a lazily\n * loaded image has no height until it arrives, and either one appearing ABOVE\n * the target pushes it back down — the reader watches the page scroll to the\n * right place and then drift away from it. Measured: the target ended 731px\n * down a 709px pane, i.e. just off the bottom.\n *\n * So the scroll is repeated whenever the container resizes, for a short window\n * and no longer. The window is a deliberate ceiling rather than a guess at how\n * long rendering takes: after it, the reader owns the scroll position, and\n * yanking it back because a late image loaded would be worse than being a few\n * pixels off.\n */\nexport function scrollToDocAnchor(\n hash: string,\n root: Element = document.body,\n settleMs = 1500,\n): () => void {\n let stopped = false;\n const go = () => {\n if (stopped) return;\n const target = findDocAnchor(hash, root);\n if (target) placeInScroller(target);\n };\n // Two frames: markdown → hast → DOM, then layout.\n const raf1 = requestAnimationFrame(() => requestAnimationFrame(go));\n\n const observer =\n typeof ResizeObserver === 'undefined'\n ? null\n : new ResizeObserver(() => requestAnimationFrame(go));\n observer?.observe(root);\n const timer = setTimeout(() => {\n stopped = true;\n observer?.disconnect();\n }, settleMs);\n\n return () => {\n stopped = true;\n cancelAnimationFrame(raf1);\n observer?.disconnect();\n clearTimeout(timer);\n };\n}\n\n/**\n * A shareable url for an anchor on the current page — READABLE.\n *\n * `new URL(…).toString()` percent-encodes the fragment, so a Russian heading\n * becomes forty characters of `%D0%BF%D0%BE…`. That is correct and unusable:\n * the entire point of this link is being pasted into a message where a person\n * reads it, and a wall of escapes tells them nothing about where it goes.\n * Browsers accept a literal fragment and encode it themselves on navigation.\n *\n * The slug is still escaped if it contains anything that would change how the\n * url PARSES — `#`, `?`, whitespace, or a stray `%`. `github-slugger` does not\n * emit those, but this is the one place where being wrong produces a link that\n * silently points somewhere else.\n */\nexport function docShareUrl(slug: string, href: string = window.location.href): string {\n const url = new URL(href);\n const safe = /[#?%\\s]/.test(slug) ? encodeURIComponent(slug) : slug;\n return `${url.origin}${url.pathname}${url.search}#${safe}`;\n}\n","'use client';\nimport { useCallback, useEffect, useState } from 'react';\nimport { THEME_STORAGE_KEY } from './theme-storage-key.js';\n\n/**\n * Re-exported here so most consumers only need one import path. A pre-paint\n * bootstrap is the one exception — `examples/next/app/layout.tsx` imports the\n * constant from the separate, hook-free `@burojs/ui-shadcn/theme-storage-key`\n * subpath instead; see `./theme-storage-key.js` for why a Server Component\n * cannot import it from here.\n */\nexport { THEME_STORAGE_KEY };\n\n/** What the user picked. `'system'` means \"no explicit choice\" — nothing is\n * persisted for it; its absence from storage IS the `system` state. */\nexport type ThemeChoice = 'light' | 'dark' | 'system';\n\n/** Every theme choice, in the order a control offers them. */\nexport const THEME_CHOICES: readonly ThemeChoice[] = ['light', 'dark', 'system'];\n\n/** What is actually painted. Never `'system'` — every reader (Toaster,\n * CodeMirror, Mermaid) only ever needs to know light or dark. */\nexport type ResolvedTheme = 'light' | 'dark';\n\nexport interface UseThemeResult {\n /** The persisted preference, including `'system'`. Only the theme\n * submenu (the one place with a three-way control) needs this. */\n choice: ThemeChoice;\n /** The resolved light/dark value, kept in sync with the `dark` class on\n * `<html>`. This is what every read-only consumer wants. */\n resolved: ResolvedTheme;\n /** Set a new choice: applies the `dark` class immediately and persists\n * (or, for `'system'`, clears) the preference. */\n setTheme: (next: ThemeChoice) => void;\n}\n\nfunction systemPrefersDark(): boolean {\n return (\n typeof window !== 'undefined' &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-color-scheme: dark)').matches\n );\n}\n\nfunction readChoice(): ThemeChoice {\n if (typeof window === 'undefined') return 'system';\n try {\n const saved = window.localStorage.getItem(THEME_STORAGE_KEY);\n if (saved === 'light' || saved === 'dark') return saved;\n } catch {\n // localStorage may be disabled — fall through to system\n }\n return 'system';\n}\n\nfunction resolveTheme(choice: ThemeChoice): ResolvedTheme {\n if (choice === 'light' || choice === 'dark') return choice;\n return systemPrefersDark() ? 'dark' : 'light';\n}\n\nfunction applyDomClass(resolved: ResolvedTheme): void {\n if (typeof document === 'undefined') return;\n document.documentElement.classList.toggle('dark', resolved === 'dark');\n}\n\nfunction persistChoice(choice: ThemeChoice): void {\n if (typeof window === 'undefined') return;\n try {\n if (choice === 'system') window.localStorage.removeItem(THEME_STORAGE_KEY);\n else window.localStorage.setItem(THEME_STORAGE_KEY, choice);\n } catch {\n // localStorage may be disabled — the class flip already applied.\n }\n}\n\ninterface ThemeState {\n choice: ThemeChoice;\n resolved: ResolvedTheme;\n}\n\n/**\n * Single source of truth for light/dark/system, replacing seven places that\n * each used to know about the theme independently (a bootstrap script, a\n * command-palette action, a submenu, an unused kit component, and three\n * MutationObserver-based readers).\n *\n * Read-only consumers (Toaster, CodeMirror, Mermaid) only need `resolved`\n * and don't call the public `setTheme` — they stay in sync via the\n * `<html class=\"dark\">` MutationObserver below, which fires regardless of\n * WHICH writer flipped it. That is deliberate: it's the same mechanism these\n * three already used individually before this hook existed, now shared\n * instead of duplicated, and it means a future writer doesn't need to know\n * about every reader. Every mounted instance — including these \"read-only\"\n * ones — still runs its OWN `onSystemChange` listener below and applies the\n * class itself on an OS change; see that listener for why re-reading storage\n * there (rather than trusting this instance's local `choice`) is required\n * for that to be safe with several instances mounted at once.\n *\n * `choice` (the three-way value, including `'system'`) is NOT synchronized\n * the same way — the `dark` class alone can't tell \"explicitly dark\" apart\n * from \"system, and the system happens to be dark\". The only consumer that\n * needs `choice` is the theme submenu, and Radix unmounts a closed dropdown's\n * content, so it re-reads storage fresh every time it opens; no live\n * cross-component sync of `choice` is needed in practice.\n *\n * NOT used by the bootstrap `<script>` in `app/layout.tsx` — see the comment\n * there for why a hook cannot do that job.\n */\nexport function useTheme(): UseThemeResult {\n // Safe default for the very first client render, corrected synchronously\n // by the effect below on mount — mirrors the pattern the three readers\n // used before migrating here (start light/false, fix it post-mount) so no\n // component newly grows a hydration-mismatch warning.\n const [state, setState] = useState<ThemeState>({ choice: 'system', resolved: 'light' });\n\n useEffect(() => {\n const choice = readChoice();\n setState({ choice, resolved: resolveTheme(choice) });\n\n const root = document.documentElement;\n const observer = new MutationObserver(() => {\n const resolved: ResolvedTheme = root.classList.contains('dark') ? 'dark' : 'light';\n setState((prev) => (prev.resolved === resolved ? prev : { ...prev, resolved }));\n });\n observer.observe(root, { attributes: true, attributeFilter: ['class'] });\n\n const mql =\n typeof window.matchMedia === 'function'\n ? window.matchMedia('(prefers-color-scheme: dark)')\n : undefined;\n const onSystemChange = (event: { matches: boolean }) => {\n // Re-read the stored choice instead of trusting this instance's own\n // `prev.choice`: `choice` is captured once at mount and never\n // resynchronised, but several `useTheme()` instances mount at once\n // (Toaster, CodeMirror, Mermaid, the admin layout, the theme submenu\n // while open). If a later explicit pick only updates the instance that\n // made it, every OTHER instance still believes `system` applies and\n // would repaint the whole app back to the OS value on the next\n // preference flip — silently overriding storage, which is why this\n // must not trust local state. Storage is already the single source of\n // truth (`setTheme('system')` clears the key), so a fresh read here is\n // correct in both directions: an explicit pick is respected, and\n // `system` is honoured even once the one instance that set it has\n // unmounted. Every other live instance's listener fires on the same OS\n // change and independently reaches the same conclusion, so several\n // instances may call `applyDomClass` with the same value for one\n // event — redundant, but harmless, and not worth coordinating.\n const choice = readChoice();\n if (choice !== 'system') return;\n const resolved: ResolvedTheme = event.matches ? 'dark' : 'light';\n applyDomClass(resolved);\n setState((prev) =>\n prev.choice === choice && prev.resolved === resolved ? prev : { choice, resolved },\n );\n };\n mql?.addEventListener('change', onSystemChange);\n\n return () => {\n observer.disconnect();\n mql?.removeEventListener('change', onSystemChange);\n };\n }, []);\n\n const setTheme = useCallback((next: ThemeChoice) => {\n const resolved = resolveTheme(next);\n applyDomClass(resolved);\n persistChoice(next);\n setState({ choice: next, resolved });\n }, []);\n\n return { choice: state.choice, resolved: state.resolved, setTheme };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@burojs/ui-shadcn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Buro: default shadcn+Tailwind UI kit with required testid attributes",
|
|
6
6
|
"type": "module",
|
|
@@ -120,11 +120,11 @@
|
|
|
120
120
|
"tw-animate-css": "^1.4.0",
|
|
121
121
|
"unist-util-visit": "^5",
|
|
122
122
|
"zod": "^3.24.0",
|
|
123
|
-
"@burojs/core": "0.
|
|
124
|
-
"@burojs/field-types": "0.
|
|
125
|
-
"@burojs/file-storage": "0.
|
|
126
|
-
"@burojs/i18n-core": "0.
|
|
127
|
-
"@burojs/react": "0.
|
|
123
|
+
"@burojs/core": "0.6.0",
|
|
124
|
+
"@burojs/field-types": "0.6.0",
|
|
125
|
+
"@burojs/file-storage": "0.6.0",
|
|
126
|
+
"@burojs/i18n-core": "0.6.0",
|
|
127
|
+
"@burojs/react": "0.6.0"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
130
|
"react": "^19.0.0",
|
|
@@ -147,9 +147,9 @@
|
|
|
147
147
|
"react-dom": "^19.0.0",
|
|
148
148
|
"typescript": "^5.5.0",
|
|
149
149
|
"vitest": "^4.0.0",
|
|
150
|
-
"@burojs/context": "0.
|
|
151
|
-
"@burojs/export": "0.
|
|
152
|
-
"@burojs/testing": "0.
|
|
150
|
+
"@burojs/context": "0.6.0",
|
|
151
|
+
"@burojs/export": "0.6.0",
|
|
152
|
+
"@burojs/testing": "0.6.0",
|
|
153
153
|
"@burojs/tooling-eslint": "0.0.0",
|
|
154
154
|
"@burojs/tooling-globals": "0.0.0",
|
|
155
155
|
"@burojs/tooling-tsconfig": "0.0.0"
|