@aiquants/virtualscroll 1.18.5 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +20 -13
  2. package/dist/ScrollPane.d.cts +2 -0
  3. package/dist/ScrollPane.d.ts +2 -0
  4. package/dist/ScrollPane.d.ts.map +1 -1
  5. package/dist/VirtualScroll.d.cts +2 -0
  6. package/dist/VirtualScroll.d.ts +2 -0
  7. package/dist/VirtualScroll.d.ts.map +1 -1
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.cts +6 -0
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1115 -1112
  13. package/dist/styles/virtualscroll.css +1 -1
  14. package/dist/styles/virtualscroll.standalone.css +3 -0
  15. package/package.json +6 -4
  16. package/src/ScrollBar.spec.tsx +620 -0
  17. package/src/ScrollBar.tsx +1397 -0
  18. package/src/ScrollPane.spec.tsx +482 -0
  19. package/src/ScrollPane.tsx +913 -0
  20. package/src/TapScrollCircle.spec.tsx +275 -0
  21. package/src/TapScrollCircle.tsx +363 -0
  22. package/src/VirtualScroll.spec.ts +623 -0
  23. package/src/VirtualScroll.tsx +1891 -0
  24. package/src/cli.server.spec.ts +137 -0
  25. package/src/cli.server.ts +110 -0
  26. package/src/index.ts +23 -0
  27. package/src/logger.spec.ts +128 -0
  28. package/src/logger.ts +229 -0
  29. package/src/styles/components.entry.css +9 -0
  30. package/src/styles/standalone.entry.css +11 -0
  31. package/src/styles/virtualscroll.css +296 -0
  32. package/src/tapScrollCircleSampleVisual.tsx +74 -0
  33. package/src/useFenwickMapTree.huge.spec.ts +388 -0
  34. package/src/useFenwickMapTree.spec.ts +1518 -0
  35. package/src/useFenwickMapTree.ts +1368 -0
  36. package/src/useHeightCache.ts +32 -0
  37. package/src/useLruCache.spec.ts +382 -0
  38. package/src/useLruCache.ts +301 -0
  39. package/src/utils.spec.ts +39 -0
  40. package/src/utils.ts +16 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Regression tests for the CLI entry point: usage guidance must always be
3
+ * printed via console (never suppressed by the Logger's default WARN level),
4
+ * and importing the module must not trigger the CLI.
5
+ * CLI エントリポイントの回帰テスト。usage 案内が Logger の既定レベル (WARN) に
6
+ * 抑制されず常時 console 表示されること、および import 時に CLI が起動しないことの検証。
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
9
+
10
+ // spawn / existsSync をモックし、実プロセス起動・実ファイル参照なしで CLI 分岐を検証する
11
+ vi.mock("node:child_process", async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import("node:child_process")>()
13
+ const spawnMock = vi.fn(() => ({ on: vi.fn() }))
14
+ return {
15
+ ...actual,
16
+ default: { ...actual, spawn: spawnMock },
17
+ spawn: spawnMock,
18
+ }
19
+ })
20
+ vi.mock("node:fs", async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import("node:fs")>()
22
+ // interop で default 経由の名前解決が行われるため、default 側にも同一モックを載せる
23
+ const existsSyncMock = vi.fn(() => true)
24
+ return {
25
+ ...actual,
26
+ default: { ...actual, existsSync: existsSyncMock },
27
+ existsSync: existsSyncMock,
28
+ }
29
+ })
30
+
31
+ import { spawn } from "node:child_process"
32
+ import { existsSync } from "node:fs"
33
+ import { runCli } from "./cli.server"
34
+
35
+ const USAGE_MESSAGE = "Usage: npx @aiquants/virtualscroll demo"
36
+
37
+ describe("cli.server", () => {
38
+ let consoleErrorSpy: ReturnType<typeof vi.spyOn>
39
+ let exitSpy: ReturnType<typeof vi.spyOn>
40
+
41
+ beforeEach(() => {
42
+ // console.error を捕捉して usage 案内の常時表示を検証する
43
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
44
+ // process.exit は throw に差し替え、以降のコードへ進まない実挙動を再現する
45
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
46
+ throw new Error(`process.exit(${code})`)
47
+ }) as never)
48
+ vi.mocked(spawn).mockClear()
49
+ vi.mocked(existsSync).mockClear()
50
+ })
51
+
52
+ afterEach(() => {
53
+ consoleErrorSpy.mockRestore()
54
+ exitSpy.mockRestore()
55
+ })
56
+
57
+ it("prints unknown command and usage to console.error and exits 1", () => {
58
+ // 未知コマンドではコマンド名 + usage が console.error 直で表示される (Logger レベル非依存)
59
+ expect(() => runCli(["foo"])).toThrow("process.exit(1)")
60
+ const messages = consoleErrorSpy.mock.calls.map((call) => String(call[0]))
61
+ expect(messages).toContain("Unknown command: foo")
62
+ expect(messages).toContain(USAGE_MESSAGE)
63
+ })
64
+
65
+ it("prints usage without 'Unknown command: undefined' when no args are given", () => {
66
+ // 引数なしでは undefined を埋め込んだ Unknown command 行を出さず usage のみ表示する
67
+ expect(() => runCli([])).toThrow("process.exit(1)")
68
+ const messages = consoleErrorSpy.mock.calls.map((call) => String(call[0]))
69
+ expect(messages).toContain(USAGE_MESSAGE)
70
+ expect(messages.some((message) => message.includes("Unknown command"))).toBe(false)
71
+ })
72
+
73
+ it("prints source-checkout guidance via console.error and exits 1 when demo dir is missing", () => {
74
+ // demo ディレクトリ不在 (published tarball 相当) では案内を常時表示して exit 1 する
75
+ vi.mocked(existsSync).mockReturnValue(false)
76
+ expect(() => runCli(["demo"])).toThrow("process.exit(1)")
77
+ const messages = consoleErrorSpy.mock.calls.map((call) => String(call[0]))
78
+ expect(messages.some((message) => message.includes("only available in a source checkout"))).toBe(true)
79
+ expect(vi.mocked(spawn)).not.toHaveBeenCalled()
80
+ })
81
+
82
+ it("announces startup and spawns the demo server when demo dir exists", () => {
83
+ // demo ディレクトリが存在する場合は起動メッセージを常時表示し pnpm run dev を spawn する
84
+ vi.mocked(existsSync).mockReturnValue(true)
85
+ runCli(["demo"])
86
+ const messages = consoleErrorSpy.mock.calls.map((call) => String(call[0]))
87
+ expect(messages).toContain("Starting demo server...")
88
+ expect(vi.mocked(spawn)).toHaveBeenCalledWith("pnpm", ["run", "dev"], expect.objectContaining({ stdio: "inherit" }))
89
+ expect(exitSpy).not.toHaveBeenCalled()
90
+ })
91
+
92
+ it("propagates a non-zero child exit code to process.exitCode via the close handler", () => {
93
+ // 子プロセスが非ゼロ終了したら親の exitCode に伝播する (シェル連結/CI で失敗が成功扱いにならない)
94
+ const previousExitCode = process.exitCode
95
+ try {
96
+ vi.mocked(existsSync).mockReturnValue(true)
97
+ runCli(["demo"])
98
+ const onMock = vi.mocked(spawn).mock.results[0]?.value.on as ReturnType<typeof vi.fn>
99
+ const closeHandler = onMock.mock.calls.find((call) => call[0] === "close")?.[1] as (code: number | null) => void
100
+ expect(closeHandler).toBeTypeOf("function")
101
+ closeHandler(3)
102
+ expect(process.exitCode).toBe(3)
103
+ // code=null (シグナル終了等) は汎用の 1 にフォールバックする
104
+ process.exitCode = undefined
105
+ closeHandler(null)
106
+ expect(process.exitCode).toBe(1)
107
+ // 正常終了 (code=0) では exitCode を汚染しない
108
+ process.exitCode = undefined
109
+ closeHandler(0)
110
+ expect(process.exitCode).toBeUndefined()
111
+ } finally {
112
+ process.exitCode = previousExitCode
113
+ }
114
+ })
115
+
116
+ it("sets process.exitCode to 1 when the child emits an error (spawn failure)", () => {
117
+ // spawn 自体の失敗 (コマンド不在等) も非ゼロ終了として親へ伝播する
118
+ const previousExitCode = process.exitCode
119
+ try {
120
+ vi.mocked(existsSync).mockReturnValue(true)
121
+ runCli(["demo"])
122
+ const onMock = vi.mocked(spawn).mock.results[0]?.value.on as ReturnType<typeof vi.fn>
123
+ const errorHandler = onMock.mock.calls.find((call) => call[0] === "error")?.[1] as (err: Error) => void
124
+ expect(errorHandler).toBeTypeOf("function")
125
+ errorHandler(new Error("spawn pnpm ENOENT"))
126
+ expect(process.exitCode).toBe(1)
127
+ } finally {
128
+ process.exitCode = previousExitCode
129
+ }
130
+ })
131
+
132
+ it("does not run the CLI on import (direct-run guard)", () => {
133
+ // モジュール import 自体では spawn も exit も発生していない (トップレベルガードの検証)
134
+ // 上記テスト以外での呼び出しが無いことは各テストの mockClear + アサーションで担保される
135
+ expect(vi.mocked(spawn)).not.toHaveBeenCalled()
136
+ })
137
+ })
@@ -0,0 +1,110 @@
1
+ /**
2
+ * CLI entry point for the @aiquants/virtualscroll package. Handles the
3
+ * "demo" command that launches the local demo server, and prints usage
4
+ * guidance for unknown or missing commands.
5
+ * @aiquants/virtualscroll パッケージの CLI エントリポイント。demo コマンドによる
6
+ * ローカルデモサーバー起動と、不明・未指定コマンド時の usage 案内の担当。
7
+ */
8
+ import { spawn } from "node:child_process"
9
+ import { existsSync, realpathSync } from "node:fs"
10
+ import path from "node:path"
11
+ import { fileURLToPath, pathToFileURL } from "node:url"
12
+ import { Logger } from "./logger"
13
+
14
+ const __filename = fileURLToPath(import.meta.url)
15
+ const __dirname = path.dirname(__filename)
16
+
17
+ // usage 案内文。Logger の既定レベル (WARN) では info が抑制されるため、
18
+ // usage/案内は Logger を経由せず console 直で常時表示する。
19
+ const USAGE_MESSAGE = "Usage: npx @aiquants/virtualscroll demo"
20
+
21
+ /**
22
+ * Run the virtualscroll CLI with the given arguments: start the demo server
23
+ * for the "demo" command, otherwise print usage guidance and exit with code 1.
24
+ * When the demo server exits with a non-zero code or fails to spawn, the
25
+ * failure is propagated to the parent process via process.exitCode.
26
+ * 与えられた引数で virtualscroll CLI を実行し、demo コマンドならデモサーバーを
27
+ * 起動、それ以外は usage 案内を表示して終了コード 1 で終了する処理。デモサーバーが
28
+ * 非ゼロ終了または起動失敗した場合は process.exitCode で親プロセスへ失敗を伝播。
29
+ *
30
+ * @param {string[]} args - Command-line arguments (without the node/script prefix).
31
+ * @returns {void} Nothing. デモ起動または usage 表示後の終了のみで戻り値なし。
32
+ */
33
+ export function runCli(args: string[]): void {
34
+ if (args[0] === "demo") {
35
+ // The demo directory lives next to the built CLI in a source checkout
36
+ // (dist/cli.js -> ../demo). It is intentionally NOT part of the published
37
+ // npm tarball ("files" excludes it), because the demo requires its own
38
+ // demo/ tree plus a full node_modules install that cannot be shipped.
39
+ // 公開 tarball には demo/ が含まれないため、ソースチェックアウト以外では
40
+ // ここで存在確認し、ENOENT でクラッシュさせず明確な案内を出して終了する。
41
+ const demoPath = path.join(__dirname, "..", "demo")
42
+
43
+ if (!existsSync(demoPath)) {
44
+ // 案内は Logger のレベル設定に左右されないよう console.error 直で表示する
45
+ console.error(
46
+ "The 'demo' command is only available in a source checkout of @aiquants/virtualscroll.\n" + "The demo app is not bundled in the published npm package.\n" + "To run it, clone the repository and inside the 'demo/' directory run:\n" + " pnpm install && pnpm dev",
47
+ )
48
+ process.exit(1)
49
+ }
50
+
51
+ // 起動メッセージも Logger.info では既定レベルで抑制されるため console 直で常時表示する
52
+ // (noConsole の allow 対象かつ stdout を汚さない console.error を使用)
53
+ console.error("Starting demo server...")
54
+
55
+ const child = spawn("pnpm", ["run", "dev"], {
56
+ cwd: demoPath,
57
+ stdio: "inherit",
58
+ shell: process.platform === "win32",
59
+ })
60
+
61
+ child.on("close", (code) => {
62
+ if (code !== 0) {
63
+ // 子プロセスの失敗を親 CLI の終了コードへ伝播する (シェル連結や CI で失敗を成功扱いさせない)。
64
+ // シグナル終了等で code が null の場合は汎用の 1 にフォールバックする
65
+ Logger.error(`Demo server process exited with code ${code}`)
66
+ process.exitCode = code ?? 1
67
+ }
68
+ })
69
+
70
+ child.on("error", (err) => {
71
+ // spawn 自体の失敗 (コマンド不在等) も非ゼロ終了として親へ伝播する
72
+ Logger.error("Failed to start demo server:", err)
73
+ process.exitCode = 1
74
+ })
75
+ } else {
76
+ // 引数なしの場合は "Unknown command: undefined" を出さず usage のみ表示する
77
+ if (args.length > 0) {
78
+ console.error(`Unknown command: ${args[0]}`)
79
+ }
80
+ console.error(USAGE_MESSAGE)
81
+ process.exit(1)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Determine whether this module is the direct execution entry (bin script)
87
+ * rather than an import from tests or other modules.
88
+ * このモジュールが (テスト等からの import ではなく) bin スクリプトとして
89
+ * 直接実行されたエントリかどうかの判定。
90
+ *
91
+ * @returns {boolean} True when executed directly as the process entry script. 直接実行時に true。
92
+ */
93
+ function isDirectRun(): boolean {
94
+ const entryPath = process.argv[1]
95
+ if (!entryPath) {
96
+ return false
97
+ }
98
+ try {
99
+ // npx の bin シンボリックリンク経由でも一致するよう realpath で実体を解決して比較する
100
+ return pathToFileURL(realpathSync(entryPath)).href === import.meta.url
101
+ } catch {
102
+ // エントリパスが解決不能な場合は直接実行とみなさない
103
+ return false
104
+ }
105
+ }
106
+
107
+ // bin として直接実行された場合のみ CLI を起動する (spec からの import 時は起動しない)
108
+ if (isDirectRun()) {
109
+ runCli(process.argv.slice(2))
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @aiquants/virtualscroll
3
+ *
4
+ * High-performance virtual scrolling component for React with variable item heights.
5
+ * 可変なアイテム高さに対応したReact用の高性能仮想スクロールコンポーネント。
6
+ */
7
+
8
+ export { ScrollBar, type ScrollBarProps, type ScrollBarTapCircleOptions, type ScrollBarThumbOverlayRenderProps } from "./ScrollBar.tsx"
9
+ export { ScrollPane, type ScrollPaneContentInsets, type ScrollPaneHandle, type ScrollPaneInertiaOptions, type ScrollPaneProps } from "./ScrollPane.tsx"
10
+ export type { TapScrollCircleRenderProps } from "./TapScrollCircle.tsx"
11
+ export { tapScrollCircleSampleVisual } from "./tapScrollCircleSampleVisual.tsx"
12
+ export { FenwickMapTree, useFenwickMapTree } from "./useFenwickMapTree.ts"
13
+ export { useHeightCache } from "./useHeightCache.ts"
14
+ export { useLruCache } from "./useLruCache.ts"
15
+ export { minmax } from "./utils.ts"
16
+ export {
17
+ VirtualScroll,
18
+ type VirtualScrollBehaviorOptions,
19
+ type VirtualScrollHandle,
20
+ type VirtualScrollProps,
21
+ type VirtualScrollRange,
22
+ type VirtualScrollScrollBarOptions,
23
+ } from "./VirtualScroll.tsx"
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @fileoverview Tests for Logger.
3
+ * Logger のテスト。
4
+ */
5
+
6
+ import { beforeEach, describe, expect, it, vi } from "vitest"
7
+ import { type ILogger, Logger, LogLevel } from "./logger"
8
+
9
+ const createImpl = (): ILogger => ({
10
+ debug: vi.fn(),
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ error: vi.fn(),
14
+ })
15
+
16
+ describe("Logger", () => {
17
+ let impl: ILogger
18
+
19
+ beforeEach(() => {
20
+ impl = createImpl()
21
+ })
22
+
23
+ it("レベル抑制時は impl を呼ばない", () => {
24
+ const logger = new Logger(LogLevel.WARN, "[t]", impl)
25
+ logger.debug("hidden")
26
+ logger.info("hidden")
27
+ expect(impl.debug).not.toHaveBeenCalled()
28
+ expect(impl.info).not.toHaveBeenCalled()
29
+ })
30
+
31
+ it("有効レベルでは prefix 付きで出力する(後方互換)", () => {
32
+ const logger = new Logger(LogLevel.DEBUG, "[t]", impl)
33
+ logger.debug("hello", 1, 2)
34
+ expect(impl.debug).toHaveBeenCalledWith("[t] hello", 1, 2)
35
+
36
+ logger.warn({ a: 1 })
37
+ expect(impl.warn).toHaveBeenCalledWith("[t]", { a: 1 })
38
+ })
39
+
40
+ describe("isEnabled", () => {
41
+ it("現在のレベルで出力されるレベルのみ true", () => {
42
+ const logger = new Logger(LogLevel.WARN, "[t]", impl)
43
+ expect(logger.isEnabled(LogLevel.DEBUG)).toBe(false)
44
+ expect(logger.isEnabled(LogLevel.INFO)).toBe(false)
45
+ expect(logger.isEnabled(LogLevel.WARN)).toBe(true)
46
+ expect(logger.isEnabled(LogLevel.ERROR)).toBe(true)
47
+ })
48
+
49
+ it("静的インスタンスにも isEnabled がある", () => {
50
+ Logger.setLevel(LogLevel.ERROR)
51
+ expect(Logger.isEnabled(LogLevel.WARN)).toBe(false)
52
+ expect(Logger.isEnabled(LogLevel.ERROR)).toBe(true)
53
+ Logger.setLevel(LogLevel.WARN)
54
+ })
55
+ })
56
+
57
+ describe("遅延評価(サンク)引数", () => {
58
+ it("抑制時はサンクを一切呼ばない(オブジェクト生成/DOM 読み取りを回避)", () => {
59
+ const logger = new Logger(LogLevel.WARN, "[t]", impl)
60
+ const messageThunk = vi.fn(() => "expensive message")
61
+ const paramThunk = vi.fn(() => ({ heavy: true }))
62
+
63
+ logger.debug(messageThunk, paramThunk)
64
+
65
+ expect(messageThunk).not.toHaveBeenCalled()
66
+ expect(paramThunk).not.toHaveBeenCalled()
67
+ expect(impl.debug).not.toHaveBeenCalled()
68
+ })
69
+
70
+ it("有効時はサンクを解決して出力する", () => {
71
+ const logger = new Logger(LogLevel.DEBUG, "[t]", impl)
72
+ const messageThunk = vi.fn(() => "expensive message")
73
+ const paramThunk = vi.fn(() => ({ heavy: true }))
74
+
75
+ logger.debug(messageThunk, paramThunk)
76
+
77
+ expect(messageThunk).toHaveBeenCalledTimes(1)
78
+ expect(paramThunk).toHaveBeenCalledTimes(1)
79
+ expect(impl.debug).toHaveBeenCalledWith("[t] expensive message", { heavy: true })
80
+ })
81
+
82
+ it("非関数値はそのまま素通し(後方互換)", () => {
83
+ const logger = new Logger(LogLevel.INFO, "[t]", impl)
84
+ logger.info("plain", { a: 1 }, 3)
85
+ expect(impl.info).toHaveBeenCalledWith("[t] plain", { a: 1 }, 3)
86
+ })
87
+
88
+ it("class を渡してもクラッシュせず、元のクラス値をそのまま出力する", () => {
89
+ const logger = new Logger(LogLevel.ERROR, "[t]", impl)
90
+ class Foo {}
91
+
92
+ // class constructor は `new` なしで呼べないが、logger 呼び出しは throw しない
93
+ expect(() => logger.error("failed with handler:", Foo)).not.toThrow()
94
+ expect(impl.error).toHaveBeenCalledWith("[t] failed with handler:", Foo)
95
+
96
+ // message 位置に class を渡してもクラッシュしない
97
+ expect(() => logger.error(Foo)).not.toThrow()
98
+ expect(impl.error).toHaveBeenCalledWith("[t]", Foo)
99
+ })
100
+
101
+ it("thunk が throw しても例外を伝播させず、元の関数値を出力する", () => {
102
+ const logger = new Logger(LogLevel.DEBUG, "[t]", impl)
103
+ const badThunk = () => {
104
+ throw new Error("boom in thunk")
105
+ }
106
+
107
+ // レベル有効時でもログ呼び出しは呼び出し元に例外を伝播させない
108
+ expect(() => logger.debug("state:", badThunk)).not.toThrow()
109
+ // 握り潰しではなく、元の関数値がログに出力される
110
+ expect(impl.debug).toHaveBeenCalledWith("[t] state:", badThunk)
111
+
112
+ // message 位置の thunk が throw するケースも同様
113
+ expect(() => logger.debug(badThunk)).not.toThrow()
114
+ expect(impl.debug).toHaveBeenCalledWith("[t]", badThunk)
115
+ })
116
+
117
+ it("抑制レベルでは throw する thunk も一切評価されない", () => {
118
+ const logger = new Logger(LogLevel.WARN, "[t]", impl)
119
+ const badThunk = vi.fn(() => {
120
+ throw new Error("boom in thunk")
121
+ })
122
+
123
+ expect(() => logger.debug(badThunk)).not.toThrow()
124
+ expect(badThunk).not.toHaveBeenCalled()
125
+ expect(impl.debug).not.toHaveBeenCalled()
126
+ })
127
+ })
128
+ })
package/src/logger.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * @module utils/logger
3
+ * @description Logger utility for consistent log management across the library.
4
+ * @description ライブラリ全体で一貫したログ管理を行うためのロガーユーティリティ。
5
+ */
6
+
7
+ /**
8
+ * @enum LogLevel
9
+ * @description Log levels for filtering output.
10
+ * @description 出力をフィルタリングするためのログレベル。
11
+ */
12
+ export enum LogLevel {
13
+ DEBUG = 0,
14
+ INFO = 1,
15
+ WARN = 2,
16
+ ERROR = 3,
17
+ NONE = 4,
18
+ }
19
+
20
+ /**
21
+ * @interface ILogger
22
+ * @description Interface for a logger object compatible with Console.
23
+ * @description Console と互換性のあるロガーオブジェクトのインターフェース。
24
+ */
25
+ export interface ILogger {
26
+ debug(message?: unknown, ...optionalParams: unknown[]): void
27
+ info(message?: unknown, ...optionalParams: unknown[]): void
28
+ warn(message?: unknown, ...optionalParams: unknown[]): void
29
+ error(message?: unknown, ...optionalParams: unknown[]): void
30
+ }
31
+
32
+ /**
33
+ * @class Logger
34
+ * @description A wrapper class for handling logging with levels and prefixes.
35
+ *
36
+ * Lazy-argument contract: any function passed as a log argument is treated as a
37
+ * lazy-evaluation thunk and is invoked (with no arguments) only when the level
38
+ * is enabled. To log a function itself as a value, wrap it as `() => fn`.
39
+ * If invoking the thunk throws (e.g. a class constructor or a failing thunk),
40
+ * the original function value is logged as-is instead of crashing the caller.
41
+ * @description レベルとプレフィックスを使用したログ記録を処理するためのラッパークラス。
42
+ *
43
+ * 遅延評価の契約: ログ引数に渡された関数は遅延評価 thunk とみなし、レベルが有効な場合のみ
44
+ * 引数なしで呼び出す。関数そのものを値としてログしたい場合は `() => fn` とラップすること。
45
+ * thunk の呼び出しが throw した場合 (class や失敗する thunk) は、呼び出し元をクラッシュ
46
+ * させずに元の関数値をそのまま出力する仕様。
47
+ */
48
+ export class Logger implements ILogger {
49
+ private level: LogLevel
50
+ private prefix: string
51
+ private impl: ILogger
52
+
53
+ /**
54
+ * @constructor
55
+ * @param {LogLevel} [level=LogLevel.WARN] - The minimum log level to output.
56
+ * @param {string} [prefix="[virtualscroll]"] - The prefix to add to all log messages.
57
+ * @param {ILogger} [impl=console] - The implementation to use for logging.
58
+ */
59
+ constructor(level: LogLevel = LogLevel.WARN, prefix: string = "[virtualscroll]", impl: ILogger = console) {
60
+ this.level = level
61
+ this.prefix = prefix
62
+ this.impl = impl
63
+ }
64
+
65
+ private static instance: Logger = new Logger(LogLevel.WARN, "[virtualscroll]")
66
+
67
+ /**
68
+ * @method setLevel
69
+ * @description Updates the current log level for the static instance.
70
+ * @description 静的インスタンスの現在のログレベルを更新します。
71
+ * @param {LogLevel} level - The new log level.
72
+ */
73
+ static setLevel(level: LogLevel): void {
74
+ Logger.instance.setLevel(level)
75
+ }
76
+
77
+ /**
78
+ * @method setLevel
79
+ * @description Updates the current log level.
80
+ * @description 現在のログレベルを更新します。
81
+ * @param {LogLevel} level - The new log level.
82
+ */
83
+ setLevel(level: LogLevel): void {
84
+ this.level = level
85
+ }
86
+
87
+ /**
88
+ * @method setImplementation
89
+ * @description Updates the logger implementation for the static instance.
90
+ * @description 静的インスタンスのロガーの実装を更新します。
91
+ * @param {ILogger} impl - The new logger implementation.
92
+ */
93
+ static setImplementation(impl: ILogger): void {
94
+ Logger.instance.setImplementation(impl)
95
+ }
96
+
97
+ /**
98
+ * @method setImplementation
99
+ * @description Updates the logger implementation.
100
+ * @description ロガーの実装を更新します。
101
+ * @param {ILogger} impl - The new logger implementation.
102
+ */
103
+ setImplementation(impl: ILogger): void {
104
+ this.impl = impl
105
+ }
106
+
107
+ /**
108
+ * @method setPrefix
109
+ * @description Updates the log prefix for the static instance.
110
+ * @description 静的インスタンスのログのプレフィックスを更新します。
111
+ * @param {string} prefix - The new prefix.
112
+ */
113
+ static setPrefix(prefix: string): void {
114
+ Logger.instance.setPrefix(prefix)
115
+ }
116
+
117
+ /**
118
+ * @method setPrefix
119
+ * @description Updates the log prefix.
120
+ * @description ログのプレフィックスを更新します。
121
+ * @param {string} prefix - The new prefix.
122
+ */
123
+ setPrefix(prefix: string): void {
124
+ this.prefix = prefix
125
+ }
126
+
127
+ /**
128
+ * @method isEnabled
129
+ * @description Returns whether the given level would be emitted at the current level (static instance).
130
+ * @description 指定したレベルが現在のログレベルで出力されるかどうかを返します(静的インスタンス)。
131
+ * @param {LogLevel} level - The level to query. / 問い合わせるレベル。
132
+ * @returns {boolean} True if a message at `level` would be emitted. / `level` のメッセージが出力されるなら true。
133
+ */
134
+ static isEnabled(level: LogLevel): boolean {
135
+ return Logger.instance.isEnabled(level)
136
+ }
137
+
138
+ /**
139
+ * @method isEnabled
140
+ * @description Returns whether the given level would be emitted at the current level.
141
+ * @description 指定したレベルが現在のログレベルで出力されるかどうかを返します。
142
+ *
143
+ * ホットパスで呼び出し側が引数(オブジェクト生成・DOM 読み取り等)を組み立てる前に
144
+ * ガードするために使う。抑制時は引数評価を丸ごと省ける。
145
+ *
146
+ * @param {LogLevel} level - The level to query. / 問い合わせるレベル。
147
+ * @returns {boolean} True if a message at `level` would be emitted. / `level` のメッセージが出力されるなら true。
148
+ */
149
+ isEnabled(level: LogLevel): boolean {
150
+ return this.level <= level
151
+ }
152
+
153
+ /**
154
+ * Resolves a possibly-lazy log argument.
155
+ * If the argument is a function it is treated as a thunk and invoked only when the
156
+ * level is enabled; if the invocation throws (class constructors cannot be called
157
+ * without `new`, or the thunk itself fails), the original function value is
158
+ * returned as-is so the log call never crashes the caller and still emits something.
159
+ * To log a function as a value, wrap it as `() => fn`.
160
+ * 遅延評価される可能性のあるログ引数を解決する処理。
161
+ *
162
+ * 関数が渡された場合はサンク (thunk) とみなし、レベルが有効なとき「だけ」呼び出す。
163
+ * これにより呼び出し側は `logger.debug(() => ({ ...expensive }))` のように書け、
164
+ * レベル抑制時にはオブジェクト生成や DOM 読み取りを回避できる。
165
+ * 呼び出しが throw した場合 (class は `new` なしで呼べず TypeError、または thunk 自体の失敗) は、
166
+ * ログ呼び出しが呼び出し元をクラッシュさせないよう元の関数値をそのまま返す (握り潰さず出力は残る)。
167
+ * 関数を値としてログしたい場合は `() => fn` とラップする。非関数値はそのまま素通しするため後方互換。
168
+ */
169
+ private static resolveLazy(value: unknown): unknown {
170
+ // 非関数値はそのまま素通し (後方互換)
171
+ if (typeof value !== "function") {
172
+ return value
173
+ }
174
+ try {
175
+ // 関数は thunk とみなして遅延評価する
176
+ return (value as () => unknown)()
177
+ } catch {
178
+ // class constructor や失敗した thunk は元の関数値をそのまま出力対象にする
179
+ return value
180
+ }
181
+ }
182
+
183
+ private formatMessage(message: unknown): unknown[] {
184
+ if (typeof message === "string") {
185
+ return [`${this.prefix} ${message}`]
186
+ }
187
+ return [this.prefix, message]
188
+ }
189
+
190
+ static debug(message?: unknown, ...optionalParams: unknown[]): void {
191
+ Logger.instance.debug(message, ...optionalParams)
192
+ }
193
+
194
+ debug(message?: unknown, ...optionalParams: unknown[]): void {
195
+ if (this.level <= LogLevel.DEBUG) {
196
+ this.impl.debug(...this.formatMessage(Logger.resolveLazy(message)), ...optionalParams.map(Logger.resolveLazy))
197
+ }
198
+ }
199
+
200
+ static info(message?: unknown, ...optionalParams: unknown[]): void {
201
+ Logger.instance.info(message, ...optionalParams)
202
+ }
203
+
204
+ info(message?: unknown, ...optionalParams: unknown[]): void {
205
+ if (this.level <= LogLevel.INFO) {
206
+ this.impl.info(...this.formatMessage(Logger.resolveLazy(message)), ...optionalParams.map(Logger.resolveLazy))
207
+ }
208
+ }
209
+
210
+ static warn(message?: unknown, ...optionalParams: unknown[]): void {
211
+ Logger.instance.warn(message, ...optionalParams)
212
+ }
213
+
214
+ warn(message?: unknown, ...optionalParams: unknown[]): void {
215
+ if (this.level <= LogLevel.WARN) {
216
+ this.impl.warn(...this.formatMessage(Logger.resolveLazy(message)), ...optionalParams.map(Logger.resolveLazy))
217
+ }
218
+ }
219
+
220
+ static error(message?: unknown, ...optionalParams: unknown[]): void {
221
+ Logger.instance.error(message, ...optionalParams)
222
+ }
223
+
224
+ error(message?: unknown, ...optionalParams: unknown[]): void {
225
+ if (this.level <= LogLevel.ERROR) {
226
+ this.impl.error(...this.formatMessage(Logger.resolveLazy(message)), ...optionalParams.map(Logger.resolveLazy))
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Artifact A: components-only CSS (Tailwind ホスト向け).
3
+ * 手書き .aqvs-* コンポーネントクラスのみ。:root テーマ変数・ユーティリティ・preflight を含まない。
4
+ * ホストは @import "@aiquants/virtualscroll/styles/virtualscroll.css" layer(components) で読み込む。
5
+ */
6
+ @import "tailwindcss/theme.css" theme(reference);
7
+ @layer components {
8
+ @import "./virtualscroll.css";
9
+ }
@@ -0,0 +1,11 @@
1
+ /*! @aiquants/virtualscroll standalone CSS — 非 Tailwind ホスト専用。ホストの Tailwind ビルドと混在させないこと */
2
+ /*
3
+ * Artifact B: 自己完結スタンドアロン. virtualscroll は JSX ユーティリティを持たないため
4
+ * 実質 .aqvs-* コンポーネントクラスのみだが、命名統一のため standalone も配布する。
5
+ */
6
+ @layer theme, base, components, utilities;
7
+ @import "tailwindcss/theme.css" layer(theme);
8
+ @import "tailwindcss/utilities.css" layer(utilities) source(none);
9
+ @import "./virtualscroll.css" layer(components);
10
+ @source "../**/*.{ts,tsx}";
11
+ @custom-variant dark (&:where(.dark, .dark *));