@motor-hero/ui-kit 0.44.1 → 0.46.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/components/app-shell.d.ts +9 -1
- package/dist/components/app-shell.d.ts.map +1 -1
- package/dist/components/combobox.d.ts.map +1 -1
- package/dist/components/multi-combobox.d.ts.map +1 -1
- package/dist/components/update-banner.d.ts +16 -0
- package/dist/components/update-banner.d.ts.map +1 -0
- package/dist/hooks/use-infinite-scroll-sentinel.d.ts +15 -7
- package/dist/hooks/use-infinite-scroll-sentinel.d.ts.map +1 -1
- package/dist/hooks/use-version-check.d.ts +24 -0
- package/dist/hooks/use-version-check.d.ts.map +1 -0
- package/dist/index.cjs +136 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +141 -33
- package/dist/index.js.map +1 -1
- package/dist/vite.cjs +53 -0
- package/dist/vite.cjs.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +28 -0
- package/dist/vite.js.map +1 -0
- package/package.json +7 -2
- package/src/components/app-shell.tsx +16 -0
- package/src/components/combobox.tsx +18 -3
- package/src/components/multi-combobox.tsx +23 -7
- package/src/components/update-banner.tsx +53 -0
- package/src/hooks/use-infinite-scroll-sentinel.ts +30 -13
- package/src/hooks/use-version-check.ts +72 -0
- package/src/index.ts +4 -0
- package/src/vite.ts +35 -0
package/dist/vite.cjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/vite.ts
|
|
21
|
+
var vite_exports = {};
|
|
22
|
+
__export(vite_exports, {
|
|
23
|
+
versionFile: () => versionFile
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(vite_exports);
|
|
26
|
+
var import_node_fs = require("fs");
|
|
27
|
+
var import_node_path = require("path");
|
|
28
|
+
function versionFile(options = {}) {
|
|
29
|
+
const fileName = options.fileName ?? "version.json";
|
|
30
|
+
const buildTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
31
|
+
let outDir = "dist";
|
|
32
|
+
return {
|
|
33
|
+
name: "motor-hero:version-file",
|
|
34
|
+
config: () => ({
|
|
35
|
+
define: { "import.meta.env.VITE_BUILD_TIME": JSON.stringify(buildTime) }
|
|
36
|
+
}),
|
|
37
|
+
configResolved(config) {
|
|
38
|
+
outDir = config.build.outDir;
|
|
39
|
+
},
|
|
40
|
+
closeBundle() {
|
|
41
|
+
(0, import_node_fs.mkdirSync)(outDir, { recursive: true });
|
|
42
|
+
(0, import_node_fs.writeFileSync)(
|
|
43
|
+
(0, import_node_path.join)(outDir, fileName),
|
|
44
|
+
JSON.stringify({ buildTime, timestamp: Date.parse(buildTime) }, null, 2)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
50
|
+
0 && (module.exports = {
|
|
51
|
+
versionFile
|
|
52
|
+
});
|
|
53
|
+
//# sourceMappingURL=vite.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/vite.ts"],"sourcesContent":["import { mkdirSync, writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Plugin } from \"vite\"\n\n/**\n * One build id in two places: baked into the bundle as\n * `import.meta.env.VITE_BUILD_TIME` and written to `dist/version.json`.\n * `UpdateBanner` / `useVersionCheck` compare the two at runtime, so the\n * running app knows its own version instead of guessing it from a fetch.\n *\n * // vite.config.ts\n * import { versionFile } from \"@motor-hero/ui-kit/vite\"\n * export default defineConfig({ plugins: [react(), versionFile()] })\n */\nexport function versionFile(options: { fileName?: string } = {}): Plugin {\n const fileName = options.fileName ?? \"version.json\"\n const buildTime = new Date().toISOString()\n let outDir = \"dist\"\n return {\n name: \"motor-hero:version-file\",\n config: () => ({\n define: { \"import.meta.env.VITE_BUILD_TIME\": JSON.stringify(buildTime) },\n }),\n configResolved(config) {\n outDir = config.build.outDir\n },\n closeBundle() {\n mkdirSync(outDir, { recursive: true })\n writeFileSync(\n join(outDir, fileName),\n JSON.stringify({ buildTime, timestamp: Date.parse(buildTime) }, null, 2),\n )\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,qBAAyC;AACzC,uBAAqB;AAad,SAAS,YAAY,UAAiC,CAAC,GAAW;AACvE,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,aAAY,oBAAI,KAAK,GAAE,YAAY;AACzC,MAAI,SAAS;AACb,SAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ,OAAO;AAAA,MACb,QAAQ,EAAE,mCAAmC,KAAK,UAAU,SAAS,EAAE;AAAA,IACzE;AAAA,IACA,eAAe,QAAQ;AACrB,eAAS,OAAO,MAAM;AAAA,IACxB;AAAA,IACA,cAAc;AACZ,oCAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;AACrC;AAAA,YACE,uBAAK,QAAQ,QAAQ;AAAA,QACrB,KAAK,UAAU,EAAE,WAAW,WAAW,KAAK,MAAM,SAAS,EAAE,GAAG,MAAM,CAAC;AAAA,MACzE;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
/**
|
|
3
|
+
* One build id in two places: baked into the bundle as
|
|
4
|
+
* `import.meta.env.VITE_BUILD_TIME` and written to `dist/version.json`.
|
|
5
|
+
* `UpdateBanner` / `useVersionCheck` compare the two at runtime, so the
|
|
6
|
+
* running app knows its own version instead of guessing it from a fetch.
|
|
7
|
+
*
|
|
8
|
+
* // vite.config.ts
|
|
9
|
+
* import { versionFile } from "@motor-hero/ui-kit/vite"
|
|
10
|
+
* export default defineConfig({ plugins: [react(), versionFile()] })
|
|
11
|
+
*/
|
|
12
|
+
export declare function versionFile(options?: {
|
|
13
|
+
fileName?: string;
|
|
14
|
+
}): Plugin;
|
|
15
|
+
//# sourceMappingURL=vite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../src/vite.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAElC;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,MAAM,CAoBvE"}
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/vite.ts
|
|
2
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
function versionFile(options = {}) {
|
|
5
|
+
const fileName = options.fileName ?? "version.json";
|
|
6
|
+
const buildTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
7
|
+
let outDir = "dist";
|
|
8
|
+
return {
|
|
9
|
+
name: "motor-hero:version-file",
|
|
10
|
+
config: () => ({
|
|
11
|
+
define: { "import.meta.env.VITE_BUILD_TIME": JSON.stringify(buildTime) }
|
|
12
|
+
}),
|
|
13
|
+
configResolved(config) {
|
|
14
|
+
outDir = config.build.outDir;
|
|
15
|
+
},
|
|
16
|
+
closeBundle() {
|
|
17
|
+
mkdirSync(outDir, { recursive: true });
|
|
18
|
+
writeFileSync(
|
|
19
|
+
join(outDir, fileName),
|
|
20
|
+
JSON.stringify({ buildTime, timestamp: Date.parse(buildTime) }, null, 2)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
versionFile
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=vite.js.map
|
package/dist/vite.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/vite.ts"],"sourcesContent":["import { mkdirSync, writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport type { Plugin } from \"vite\"\n\n/**\n * One build id in two places: baked into the bundle as\n * `import.meta.env.VITE_BUILD_TIME` and written to `dist/version.json`.\n * `UpdateBanner` / `useVersionCheck` compare the two at runtime, so the\n * running app knows its own version instead of guessing it from a fetch.\n *\n * // vite.config.ts\n * import { versionFile } from \"@motor-hero/ui-kit/vite\"\n * export default defineConfig({ plugins: [react(), versionFile()] })\n */\nexport function versionFile(options: { fileName?: string } = {}): Plugin {\n const fileName = options.fileName ?? \"version.json\"\n const buildTime = new Date().toISOString()\n let outDir = \"dist\"\n return {\n name: \"motor-hero:version-file\",\n config: () => ({\n define: { \"import.meta.env.VITE_BUILD_TIME\": JSON.stringify(buildTime) },\n }),\n configResolved(config) {\n outDir = config.build.outDir\n },\n closeBundle() {\n mkdirSync(outDir, { recursive: true })\n writeFileSync(\n join(outDir, fileName),\n JSON.stringify({ buildTime, timestamp: Date.parse(buildTime) }, null, 2),\n )\n },\n }\n}\n"],"mappings":";AAAA,SAAS,WAAW,qBAAqB;AACzC,SAAS,YAAY;AAad,SAAS,YAAY,UAAiC,CAAC,GAAW;AACvE,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,aAAY,oBAAI,KAAK,GAAE,YAAY;AACzC,MAAI,SAAS;AACb,SAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ,OAAO;AAAA,MACb,QAAQ,EAAE,mCAAmC,KAAK,UAAU,SAAS,EAAE;AAAA,IACzE;AAAA,IACA,eAAe,QAAQ;AACrB,eAAS,OAAO,MAAM;AAAA,IACxB;AAAA,IACA,cAAc;AACZ,gBAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;AACrC;AAAA,QACE,KAAK,QAAQ,QAAQ;AAAA,QACrB,KAAK,UAAU,EAAE,WAAW,WAAW,KAAK,MAAM,SAAS,EAAE,GAAG,MAAM,CAAC;AAAA,MACzE;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@motor-hero/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Componentes React reutilizáveis para o ecossistema MotorHero — shadcn/ui + Tailwind CSS v4",
|
|
5
5
|
"author": "MotorHero",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
"import": "./dist/index.js",
|
|
24
24
|
"require": "./dist/index.cjs"
|
|
25
25
|
},
|
|
26
|
-
"./styles": "./dist/styles.css"
|
|
26
|
+
"./styles": "./dist/styles.css",
|
|
27
|
+
"./vite": {
|
|
28
|
+
"types": "./dist/vite.d.ts",
|
|
29
|
+
"import": "./dist/vite.js",
|
|
30
|
+
"require": "./dist/vite.cjs"
|
|
31
|
+
}
|
|
27
32
|
},
|
|
28
33
|
"files": [
|
|
29
34
|
"dist",
|
|
@@ -20,6 +20,14 @@ export interface AppShellProps {
|
|
|
20
20
|
headerActions?: ReactNode
|
|
21
21
|
collapsible?: boolean
|
|
22
22
|
defaultCollapsed?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* Bottom of the desktop sidebar, above the collapse control — a product
|
|
25
|
+
* credit, a version, a support link. Receives the collapsed state so it
|
|
26
|
+
* can shrink with the sidebar.
|
|
27
|
+
*/
|
|
28
|
+
sidebarFooter?: ReactNode | ((collapsed: boolean) => ReactNode)
|
|
29
|
+
/** Above the header, full width of the content column — `UpdateBanner` goes here. */
|
|
30
|
+
banner?: ReactNode
|
|
23
31
|
children: ReactNode
|
|
24
32
|
}
|
|
25
33
|
|
|
@@ -37,6 +45,8 @@ export function AppShell({
|
|
|
37
45
|
headerActions,
|
|
38
46
|
collapsible = true,
|
|
39
47
|
defaultCollapsed = false,
|
|
48
|
+
sidebarFooter,
|
|
49
|
+
banner,
|
|
40
50
|
children,
|
|
41
51
|
}: AppShellProps) {
|
|
42
52
|
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
|
@@ -67,6 +77,11 @@ export function AppShell({
|
|
|
67
77
|
{collapsed ? (brandCollapsed ?? brand) : brand}
|
|
68
78
|
</div>
|
|
69
79
|
<div className="flex-1 overflow-y-auto px-2 py-4">{nav(collapsed)}</div>
|
|
80
|
+
{sidebarFooter && (
|
|
81
|
+
<div className="border-t px-3 py-2" data-slot="sidebar-footer">
|
|
82
|
+
{typeof sidebarFooter === "function" ? sidebarFooter(collapsed) : sidebarFooter}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
70
85
|
{collapsible && (
|
|
71
86
|
<div className="border-t p-2">
|
|
72
87
|
<button
|
|
@@ -104,6 +119,7 @@ export function AppShell({
|
|
|
104
119
|
|
|
105
120
|
{/* Main column */}
|
|
106
121
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
122
|
+
{banner}
|
|
107
123
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4">
|
|
108
124
|
<button
|
|
109
125
|
type="button"
|
|
@@ -93,11 +93,26 @@ export function Combobox({
|
|
|
93
93
|
const [search, setSearch] = React.useState("");
|
|
94
94
|
const isDesktop = useIsDesktop();
|
|
95
95
|
const isServer = onSearchChange !== undefined;
|
|
96
|
-
|
|
96
|
+
// Mesmo cache do multi-combobox: em modo servidor `options` é só a página
|
|
97
|
+
// carregada, então o rótulo do valor escolhido precisa sobreviver à troca de
|
|
98
|
+
// página — senão o gatilho volta ao placeholder com o valor ainda setado.
|
|
99
|
+
const labelCache = React.useRef(new Map<string, ComboboxOption>());
|
|
100
|
+
const fromPage =
|
|
97
101
|
selectedOption?.value === value
|
|
98
102
|
? selectedOption
|
|
99
|
-
:
|
|
100
|
-
|
|
103
|
+
: options.find((option) => option.value === value);
|
|
104
|
+
|
|
105
|
+
// No efeito, nunca no render — mutar a ref durante o render é impuro.
|
|
106
|
+
React.useEffect(() => {
|
|
107
|
+
for (const option of options) labelCache.current.set(option.value, option);
|
|
108
|
+
if (selectedOption)
|
|
109
|
+
labelCache.current.set(selectedOption.value, selectedOption);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const selected =
|
|
113
|
+
fromPage ??
|
|
114
|
+
(value ? labelCache.current.get(value) : undefined) ??
|
|
115
|
+
(creatable && value ? { value, label: value } : undefined);
|
|
101
116
|
|
|
102
117
|
const handleOpenChange = (next: boolean) => {
|
|
103
118
|
setOpen(next);
|
|
@@ -73,14 +73,30 @@ export function MultiCombobox({
|
|
|
73
73
|
const [open, setOpen] = React.useState(false)
|
|
74
74
|
const isDesktop = useIsDesktop()
|
|
75
75
|
const isServer = onSearchChange !== undefined
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
// Em modo servidor `options` é só a página carregada no momento, então um
|
|
77
|
+
// rótulo visto uma vez precisa sobreviver à troca de página: sem esse cache,
|
|
78
|
+
// buscar (ou paginar) fazia o chip de um valor ainda selecionado sumir do
|
|
79
|
+
// gatilho, como se nada estivesse escolhido. Prioridade de rótulo:
|
|
80
|
+
// `selectedOptions` (o salvo) → página atual → cache.
|
|
81
|
+
const labelCache = React.useRef(new Map<string, ComboboxOption>())
|
|
82
|
+
const byValue = new Map<string, ComboboxOption>()
|
|
83
|
+
for (const option of options) byValue.set(option.value, option)
|
|
84
|
+
for (const option of selectedOptions ?? []) byValue.set(option.value, option)
|
|
85
|
+
|
|
86
|
+
// Escrita no efeito, nunca no render — mutar a ref durante o render é impuro.
|
|
87
|
+
// Não pisca: no render em que a opção fica selecionável ela ainda está em
|
|
88
|
+
// `options`, e este efeito a grava antes de `options` mudar.
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
for (const [key, option] of byValue) labelCache.current.set(key, option)
|
|
83
91
|
})
|
|
92
|
+
|
|
93
|
+
// Ordem de seleção (`value`), que é estável — a antiga vinha da ordem da
|
|
94
|
+
// página carregada e mudava sozinha conforme as páginas chegavam. Um valor
|
|
95
|
+
// sem rótulo conhecido (edição sem `selectedOptions`) segue sem chip.
|
|
96
|
+
const selected = value
|
|
97
|
+
.map((v) => byValue.get(v) ?? labelCache.current.get(v))
|
|
98
|
+
.filter((option): option is ComboboxOption => option !== undefined)
|
|
99
|
+
|
|
84
100
|
const { listRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
85
101
|
enabled: isServer && !!hasMore,
|
|
86
102
|
loading,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { RefreshCw } from "lucide-react"
|
|
2
|
+
import { cn } from "../lib/utils"
|
|
3
|
+
import { type UseVersionCheckOptions, useVersionCheck } from "../hooks/use-version-check"
|
|
4
|
+
|
|
5
|
+
export interface UpdateBannerProps extends UseVersionCheckOptions {
|
|
6
|
+
title?: string
|
|
7
|
+
description?: string
|
|
8
|
+
actionLabel?: string
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A bar that appears when a newer build is deployed and stays until the
|
|
14
|
+
* person reloads — not a toast: a notice that can be missed or dismissed
|
|
15
|
+
* leaves the old bundle running against a new API. Themed with the product
|
|
16
|
+
* tokens so it matches light and dark alike. Put it in `AppShell`'s
|
|
17
|
+
* `banner` slot (or at the top of any page).
|
|
18
|
+
*/
|
|
19
|
+
export function UpdateBanner({
|
|
20
|
+
title = "Nova versão disponível",
|
|
21
|
+
description = "Atualize para continuar na versão mais recente.",
|
|
22
|
+
actionLabel = "Atualizar",
|
|
23
|
+
className,
|
|
24
|
+
...options
|
|
25
|
+
}: UpdateBannerProps) {
|
|
26
|
+
const { updateAvailable, reload } = useVersionCheck(options)
|
|
27
|
+
if (!updateAvailable) return null
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
role="status"
|
|
32
|
+
aria-live="polite"
|
|
33
|
+
data-slot="update-banner"
|
|
34
|
+
className={cn(
|
|
35
|
+
"flex flex-wrap items-center justify-between gap-3 border-b border-primary/30 bg-primary px-4 py-2 text-sm text-primary-foreground",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
40
|
+
<RefreshCw className="h-4 w-4 shrink-0 animate-spin [animation-duration:3s]" aria-hidden />
|
|
41
|
+
<span className="font-medium">{title}</span>
|
|
42
|
+
<span className="hidden truncate opacity-80 sm:inline">{description}</span>
|
|
43
|
+
</span>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={reload}
|
|
47
|
+
className="shrink-0 rounded-md bg-background px-3 py-1 text-xs font-medium text-foreground transition-colors hover:bg-background/90"
|
|
48
|
+
>
|
|
49
|
+
{actionLabel}
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Dispara `onLoadMore` quando o
|
|
5
|
-
* (raiz = a lista
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Dispara `onLoadMore` quando o sentinela entra na viewport da lista rolável
|
|
5
|
+
* (raiz = a lista, não a janela — ela rola por dentro do Popover/Drawer). Usa
|
|
6
|
+
* refs "latest" para `loading`/`onLoadMore` e não reassina o observer a cada
|
|
7
|
+
* render.
|
|
8
|
+
*
|
|
9
|
+
* Os nós entram como **estado** (callback refs), não como `useRef`: lista e
|
|
10
|
+
* sentinela vivem dentro do Portal do Popover/Drawer e só existem no DOM com o
|
|
11
|
+
* dropdown aberto, o que acontece bem depois de `enabled` virar `true`. Com
|
|
12
|
+
* `useRef` + deps `[enabled]` o efeito rodava uma única vez, com o dropdown
|
|
13
|
+
* fechado e os dois refs `null`, e o observer nunca chegava a ser registrado —
|
|
14
|
+
* a paginação por scroll simplesmente não acontecia. Guardar os nós em estado
|
|
15
|
+
* também refaz a assinatura quando o sentinela desmonta e remonta (`hasMore`
|
|
16
|
+
* oscilando), em vez de deixar o observer preso a um nó já destacado.
|
|
8
17
|
*/
|
|
9
18
|
export function useInfiniteScrollSentinel({
|
|
10
19
|
enabled,
|
|
@@ -15,21 +24,29 @@ export function useInfiniteScrollSentinel({
|
|
|
15
24
|
loading?: boolean
|
|
16
25
|
onLoadMore?: () => void
|
|
17
26
|
}) {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
27
|
+
const [listNode, setListNode] = React.useState<HTMLDivElement | null>(null)
|
|
28
|
+
const [sentinelNode, setSentinelNode] = React.useState<HTMLDivElement | null>(
|
|
29
|
+
null,
|
|
30
|
+
)
|
|
20
31
|
const loadingRef = React.useRef(loading)
|
|
21
32
|
const onLoadMoreRef = React.useRef(onLoadMore)
|
|
22
33
|
|
|
34
|
+
// Memoizadas de propósito: uma callback ref recriada a cada render é chamada
|
|
35
|
+
// com `null` e de novo com o nó em *todo* render, realimentando o estado.
|
|
36
|
+
const listRef = React.useCallback((node: HTMLDivElement | null) => {
|
|
37
|
+
setListNode(node)
|
|
38
|
+
}, [])
|
|
39
|
+
const sentinelRef = React.useCallback((node: HTMLDivElement | null) => {
|
|
40
|
+
setSentinelNode(node)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
23
43
|
React.useEffect(() => {
|
|
24
44
|
loadingRef.current = loading
|
|
25
45
|
onLoadMoreRef.current = onLoadMore
|
|
26
46
|
})
|
|
27
47
|
|
|
28
48
|
React.useEffect(() => {
|
|
29
|
-
if (!enabled) return
|
|
30
|
-
const root = listRef.current
|
|
31
|
-
const sentinel = sentinelRef.current
|
|
32
|
-
if (!root || !sentinel) return
|
|
49
|
+
if (!enabled || !listNode || !sentinelNode) return
|
|
33
50
|
|
|
34
51
|
const observer = new IntersectionObserver(
|
|
35
52
|
(entries) => {
|
|
@@ -37,11 +54,11 @@ export function useInfiniteScrollSentinel({
|
|
|
37
54
|
onLoadMoreRef.current?.()
|
|
38
55
|
}
|
|
39
56
|
},
|
|
40
|
-
{ root, rootMargin: "0px 0px 100px 0px" },
|
|
57
|
+
{ root: listNode, rootMargin: "0px 0px 100px 0px" },
|
|
41
58
|
)
|
|
42
|
-
observer.observe(
|
|
59
|
+
observer.observe(sentinelNode)
|
|
43
60
|
return () => observer.disconnect()
|
|
44
|
-
}, [enabled])
|
|
61
|
+
}, [enabled, listNode, sentinelNode])
|
|
45
62
|
|
|
46
63
|
return { listRef, sentinelRef }
|
|
47
64
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react"
|
|
2
|
+
|
|
3
|
+
export interface UseVersionCheckOptions {
|
|
4
|
+
/** The build id baked into the running bundle (see `versionFile` in `@motor-hero/ui-kit/vite`). */
|
|
5
|
+
currentBuild: string | undefined
|
|
6
|
+
/** Where the deployed build id is published. */
|
|
7
|
+
url?: string
|
|
8
|
+
intervalMs?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VersionCheckState {
|
|
12
|
+
/** The deployed build differs from the running one. */
|
|
13
|
+
updateAvailable: boolean
|
|
14
|
+
/** Grows on every poll that still sees a newer build. */
|
|
15
|
+
staleChecks: number
|
|
16
|
+
latestBuild: string | null
|
|
17
|
+
reload: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Polls the deployed `version.json` and compares its `buildTime` with the
|
|
22
|
+
* build id baked into this bundle. Nothing is inferred from a first fetch —
|
|
23
|
+
* a page loaded during a deploy, or a poll that fails while the server
|
|
24
|
+
* restarts, cannot make the running code believe it is current. Polling
|
|
25
|
+
* pauses while the tab is hidden and runs at once when it comes back.
|
|
26
|
+
*/
|
|
27
|
+
export function useVersionCheck({
|
|
28
|
+
currentBuild,
|
|
29
|
+
url = "/version.json",
|
|
30
|
+
intervalMs = 60_000,
|
|
31
|
+
}: UseVersionCheckOptions): VersionCheckState {
|
|
32
|
+
const [latestBuild, setLatestBuild] = useState<string | null>(null)
|
|
33
|
+
const [staleChecks, setStaleChecks] = useState(0)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!currentBuild) return
|
|
37
|
+
let cancelled = false
|
|
38
|
+
|
|
39
|
+
const check = async () => {
|
|
40
|
+
if (document.visibilityState === "hidden") return
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(url, { cache: "no-store" })
|
|
43
|
+
const contentType = res.headers.get("content-type") ?? ""
|
|
44
|
+
if (!res.ok || !contentType.includes("application/json")) return
|
|
45
|
+
const data: { buildTime?: unknown } = await res.json()
|
|
46
|
+
if (cancelled || typeof data.buildTime !== "string") return
|
|
47
|
+
if (data.buildTime !== currentBuild) {
|
|
48
|
+
setLatestBuild(data.buildTime)
|
|
49
|
+
setStaleChecks((n) => n + 1)
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("Version check failed", error)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
check()
|
|
57
|
+
const interval = setInterval(check, intervalMs)
|
|
58
|
+
const onVisible = () => {
|
|
59
|
+
if (document.visibilityState === "visible") check()
|
|
60
|
+
}
|
|
61
|
+
document.addEventListener("visibilitychange", onVisible)
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true
|
|
64
|
+
clearInterval(interval)
|
|
65
|
+
document.removeEventListener("visibilitychange", onVisible)
|
|
66
|
+
}
|
|
67
|
+
}, [currentBuild, url, intervalMs])
|
|
68
|
+
|
|
69
|
+
const reload = useCallback(() => window.location.reload(), [])
|
|
70
|
+
|
|
71
|
+
return { updateAvailable: latestBuild !== null, staleChecks, latestBuild, reload }
|
|
72
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,8 @@ export type {
|
|
|
64
64
|
} from "./components/theme-provider";
|
|
65
65
|
export { ThemeProvider, useTheme } from "./components/theme-provider";
|
|
66
66
|
export { Toaster } from "./components/toaster";
|
|
67
|
+
export type { UpdateBannerProps } from "./components/update-banner";
|
|
68
|
+
export { UpdateBanner } from "./components/update-banner";
|
|
67
69
|
export type { RenderLink, RenderLinkProps } from "./components/types";
|
|
68
70
|
export { defaultRenderLink } from "./components/types";
|
|
69
71
|
export type {
|
|
@@ -83,6 +85,8 @@ export {
|
|
|
83
85
|
// Hooks
|
|
84
86
|
export { useDisclosure } from "./hooks/use-disclosure";
|
|
85
87
|
export { useIsDesktop } from "./hooks/use-is-desktop";
|
|
88
|
+
export type { UseVersionCheckOptions, VersionCheckState } from "./hooks/use-version-check";
|
|
89
|
+
export { useVersionCheck } from "./hooks/use-version-check";
|
|
86
90
|
export { toast, useCustomToast } from "./hooks/use-toast";
|
|
87
91
|
export { extractApiError } from "./lib/api-error";
|
|
88
92
|
export type { CompanyTheme, CompanyThemeTokens } from "./lib/company-theme";
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { Plugin } from "vite"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One build id in two places: baked into the bundle as
|
|
7
|
+
* `import.meta.env.VITE_BUILD_TIME` and written to `dist/version.json`.
|
|
8
|
+
* `UpdateBanner` / `useVersionCheck` compare the two at runtime, so the
|
|
9
|
+
* running app knows its own version instead of guessing it from a fetch.
|
|
10
|
+
*
|
|
11
|
+
* // vite.config.ts
|
|
12
|
+
* import { versionFile } from "@motor-hero/ui-kit/vite"
|
|
13
|
+
* export default defineConfig({ plugins: [react(), versionFile()] })
|
|
14
|
+
*/
|
|
15
|
+
export function versionFile(options: { fileName?: string } = {}): Plugin {
|
|
16
|
+
const fileName = options.fileName ?? "version.json"
|
|
17
|
+
const buildTime = new Date().toISOString()
|
|
18
|
+
let outDir = "dist"
|
|
19
|
+
return {
|
|
20
|
+
name: "motor-hero:version-file",
|
|
21
|
+
config: () => ({
|
|
22
|
+
define: { "import.meta.env.VITE_BUILD_TIME": JSON.stringify(buildTime) },
|
|
23
|
+
}),
|
|
24
|
+
configResolved(config) {
|
|
25
|
+
outDir = config.build.outDir
|
|
26
|
+
},
|
|
27
|
+
closeBundle() {
|
|
28
|
+
mkdirSync(outDir, { recursive: true })
|
|
29
|
+
writeFileSync(
|
|
30
|
+
join(outDir, fileName),
|
|
31
|
+
JSON.stringify({ buildTime, timestamp: Date.parse(buildTime) }, null, 2),
|
|
32
|
+
)
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|