{"id":"biotoxin495/paginationhandler","name":"paginationhandler","scope":"biotoxin495","platform":"roblox","description":"A data processing, pagination, and index-window virtualization utility for Roblox.","version":"1.0.0","latest":"1.0.0","versions":["1.0.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"7cf493ce3606e1b489a2139249cc184bb84338dfdd2dde75d8b61990cd0ca420","likes":0,"downloads":0,"install":"forest install biotoxin495/paginationhandler","url":"https://forest.dev/p/roblox/biotoxin495/paginationhandler","files":"https://api.forest.dev/ai/package/roblox/biotoxin495/paginationhandler/files","readme":"# PaginationHandler — Framework-independent pagination and virtualization utility for Roblox\n\n**PaginationHandler**, a framework-independent data processing, pagination, and index-window virtualization utility for Roblox and Luau.\n\nBuilding a paginated interface usually means hand-rolling search, filtering, sorting, page-size math, stable item identity, and index-window virtualization by hand, and re-doing it for every new list, grid, or catalog in a project.\n\n**PaginationHandler** handles the data side of that for you. It manages the state behind paginated interfaces — searching, filtering, sorting, page navigation, stable item identity, batching, and virtualized view ranges — while leaving the actual UI entirely up to your project. It does not create a particular interface, require a `ScrollingFrame`, control page buttons, or assume an item template structure.\n\nUse it with ordinary Roblox `Instance` UI, React Luau, Fusion, another UI framework, or no renderer at all.\n\n## Quick Example\n\n```lua\nlocal PaginationHandler = require(ReplicatedStorage:WaitForChild(\"PaginationHandler\"))\n\nlocal items = {\n\t{ Id = \"iron-sword\", Name = \"Iron Sword\", Rarity = 1, Price = 100 },\n\t{ Id = \"gold-sword\", Name = \"Gold Sword\", Rarity = 3, Price = 500 },\n\t{ Id = \"health-potion\", Name = \"Health Potion\", Rarity = 2, Price = 75 },\n}\n\nlocal pagination = PaginationHandler.new({\n\tItemsPerPage = 12,\n\n\tGetItemKey = function(item)\n\t\treturn item.Id\n\tend,\n\n\tSearchPredicate = function(item, query)\n\t\treturn string.find(string.lower(item.Name), query, 1, true) ~= nil\n\tend,\n})\n\npagination.ViewChanged:Connect(function(entries, context)\n\tprint(`Page {context.State.CurrentPage} of {context.State.PageCount}`)\n\n\tfor _, entry in entries do\n\t\tprint(entry.Key, entry.Item.Name)\n\tend\nend)\n\npagination:SetData(items)\n```\n\n`ViewChanged` receives the complete active page when virtualization is disabled. When virtualization is enabled, it receives only the active virtual window and its overscan.\n\n## 🚀 Features\n\n- Paginate any array-based dataset\n- Search with a custom predicate and query normalizer\n- Apply built-in, selector-based, or predicate-based filters\n- Combine filters using `All` or `Any` behavior\n- Apply multiple ordered sort rules, selectors, or custom comparators\n- Automatically size pages from a `UIGridLayout` or `UIListLayout`\n- Preserve the current page or first visible item when data changes\n- Navigate directly or with previous/next/first/last helpers\n- Change the page size at runtime\n- Batch multiple mutations into one processing pass\n- Assign stable keys for rendering and item lookup\n- Observe processed, page, view, render, and general state changes\n- Reconcile optional renderer callbacks by stable item key\n- Virtualize an index window within the active page\n- Read state and data without exposing internal arrays\n- Use strict Luau types throughout the public API\n- No external dependencies\n\n## 🛠️ Installation\n\nAdd `PaginationHandler` as a ModuleScript somewhere accessible to the code using it, such as `ReplicatedStorage`:\n\n```text\nReplicatedStorage\n└── PaginationHandler\n```\n\nThen require it:\n\n```lua\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\n\nlocal PaginationHandler = require(ReplicatedStorage:WaitForChild(\"PaginationHandler\"))\n```\n\n## 📖 Basic Usage\n\nCreate a handler by supplying an optional configuration table.\n\n```lua\nlocal pagination = PaginationHandler.new(config)\n```\n\nEvery field is optional. Provide data either through `InitialData` at construction or with `SetData` afterward:\n\n```lua\nlocal pagination = PaginationHandler.new({\n\tItemsPerPage = 20,\n\tInitialData = items,\n})\n```\n\nWhen the handler is no longer needed, call `Destroy`:\n\n```lua\npagination:Destroy()\n```\n\n## Reading the Active Page\n\nRendering callbacks are optional. You can read the current page whenever your own UI needs it:\n\n```lua\nfor _, item in pagination:GetCurrentPageItems() do\n\tprint(item.Name)\nend\n```\n\nUse entries when you also need identity and position metadata:\n\n```lua\nfor _, entry in pagination:GetCurrentPageEntries() do\n\tprint(entry.Key)\n\tprint(entry.SourceIndex)\n\tprint(entry.ProcessedIndex)\n\tprint(entry.PageIndex)\nend\n```\n\n## Page Navigation\n\n```lua\npagination:SetPage(3)\npagination:NextPage()\npagination:PreviousPage()\npagination:FirstPage()\npagination:LastPage()\n```\n\nNavigation methods return `true` when the active page changed and `false` when it did not.\n\n```lua\nPreviousButton.Activated:Connect(function()\n\tpagination:PreviousPage()\nend)\n\nNextButton.Activated:Connect(function()\n\tpagination:NextPage()\nend)\n\npagination.Changed:Connect(function(context)\n\tPreviousButton.Active = context.State.CanGoPrevious\n\tNextButton.Active = context.State.CanGoNext\n\tPageLabel.Text = `{context.State.CurrentPage} / {context.State.PageCount}`\nend)\n```\n\nAn empty processed dataset still has a conceptual page count of `1`, while its page range and item count are `0`.\n\n## Automatic Page Sizing\n\nInstead of a fixed `ItemsPerPage`, the handler can measure a `UIGridLayout` or `UIListLayout` and calculate how many items fit inside its container.\n\n```lua\nlocal pagination = PaginationHandler.new({\n\tAutomaticPageSize = {\n\t\tContainer = ItemsContainer, -- Holds the UIGridLayout/UIListLayout\n\t\tItemTemplate = ItemTemplate, -- Required when sizing against a UIListLayout\n\t\tMinimumItems = 1,\n\t},\n\n\tGetItemKey = function(item)\n\t\treturn item.Id\n\tend,\n})\n```\n\n`Container` must hold a `UIGridLayout` or `UIListLayout`, or one can be supplied explicitly through `Layout`. `ItemTemplate` is required when sizing against a `UIListLayout`, since list items don't expose a cell size the way `UIGridLayout` does.\n\nThe handler listens for changes to the container's size, its `UIPadding`, the layout's properties, and the item template's size, automatically recalculating `ItemsPerPage` whenever any of them change. Trigger a manual recalculation with:\n\n```lua\nlocal itemsPerPage = pagination:RefreshAutomaticPageSize()\n```\n\nThis updates `ItemsPerPage` if the resolved value changed, and always returns the resolved count.\n\n## Searching\n\nThe handler normalizes a query before passing it to your search predicate. The default normalizer converts the query to lowercase.\n\n```lua\nlocal pagination = PaginationHandler.new({\n\tSearchPredicate = function(item, normalizedQuery)\n\t\tlocal name = string.lower(item.Name)\n\t\tlocal category = string.lower(item.Category)\n\n\t\treturn string.find(name, normalizedQuery, 1, true) ~= nil\n\t\t\tor string.find(category, normalizedQuery, 1, true) ~= nil\n\tend,\n})\n\npagination:SetSearchQuery(\"sword\")\npagination:SetSearchQuery(\"\") -- Clears the search\n```\n\nA non-empty query requires a search predicate. Swap it at runtime with `SetSearchPredicate`; the handler reprocesses automatically if a search is currently active:\n\n```lua\npagination:SetSearchPredicate(function(item, query)\n\treturn string.find(string.lower(item.Name), query, 1, true) ~= nil\nend)\n```\n\nYou can provide a custom normalizer when constructing the handler:\n\n```lua\nNormalizeSearchQuery = function(query)\n\treturn string.lower(string.gsub(query, \"^%s*(.-)%s*$\", \"%1\"))\nend\n```\n\n## Filtering\n\nFilters are stored by an ID of your choice. Calling `SetFilter` with the same ID replaces the previous filter.\n\n### Built-in Filter\n\n```lua\npagination:SetFilter(\"MinimumRarity\", {\n\tKeyPath = \"Rarity\",\n\tOperator = \"GreaterThanOrEqual\",\n\tValue = 2,\n})\n```\n\nNested table fields use dot-separated key paths:\n\n```lua\npagination:SetFilter(\"Tradable\", {\n\tKeyPath = \"Metadata.Tradable\",\n\tOperator = \"Equals\",\n\tValue = true,\n})\n```\n\nSupported operators are `Equals`, `NotEquals`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Contains`, and `In`. Add `Negate = true` to invert any filter.\n\n### Selector Filter\n\nUse a selector for computed values or values that cannot be reached through a table key path:\n\n```lua\npagination:SetFilter(\"TotalValue\", {\n\tSelector = function(item)\n\t\treturn item.Price * item.Quantity\n\tend,\n\tOperator = \"GreaterThan\",\n\tValue = 1_000,\n})\n```\n\n### Predicate Filter\n\nUse a predicate for arbitrary logic:\n\n```lua\npagination:SetFilter(\"CanEquip\", {\n\tPredicate = function(item)\n\t\treturn item.LevelRequirement <= playerLevel\n\t\t\tand item.Class == playerClass\n\tend,\n})\n```\n\n### Filter Combination\n\n`All` requires every active filter to pass. `Any` requires at least one active filter to pass.\n\n```lua\npagination:SetFilterMode(\"Any\")\n```\n\nRemove or clear filters with:\n\n```lua\npagination:RemoveFilter(\"MinimumRarity\")\npagination:ClearFilters()\n```\n\n## Sorting\n\nSort rules are evaluated in order. Later rules break ties left by earlier rules.\n\n```lua\npagination:SetSortRules({\n\t{\n\t\tKeyPath = \"Rarity\",\n\t\tDirection = \"Descending\",\n\t},\n\t{\n\t\tKeyPath = \"Name\",\n\t\tDirection = \"Ascending\",\n\t},\n})\n```\n\nUse a selector for computed values:\n\n```lua\npagination:AddSortRule({\n\tSelector = function(item)\n\t\treturn item.Price * item.Quantity\n\tend,\n\tDirection = \"Descending\",\n})\n```\n\nOr supply a comparator that returns a negative number, zero, or a positive number:\n\n```lua\npagination:SetSortComparator(function(a, b)\n\treturn a.DisplayOrder - b.DisplayOrder\nend)\n```\n\nWhen every configured rule considers two items equal, their source order is used as a deterministic fallback.\n\n## Page Behavior After Processing\n\nLoading initial data, searches, filters, sorting, data changes, page-size changes, and refreshes can each control where the user remains afterward.\n\nAvailable behaviors:\n\n- `FirstPage` — move to page 1\n- `PreservePage` — retain the page number, clamped to the new page count\n- `PreserveFirstItem` — keep the item that was first on the old page visible by moving to its new page; falls back to preserving the page when that item no longer exists\n\nConfigure defaults during construction:\n\n```lua\nlocal pagination = PaginationHandler.new({\n\tPageBehaviors = {\n\t\tInitialData = \"FirstPage\",\n\t\tData = \"FirstPage\",\n\t\tSearch = \"FirstPage\",\n\t\tFilter = \"FirstPage\",\n\t\tFilterMode = \"FirstPage\",\n\t\tSort = \"PreservePage\",\n\t\tPageSize = \"PreserveFirstItem\",\n\t\tRefresh = \"PreserveFirstItem\",\n\t},\n})\n```\n\nOverride a behavior for one mutation:\n\n```lua\npagination:SetSearchQuery(\"sword\", {\n\tPageBehavior = \"PreservePage\",\n})\n```\n\n## Changing the Page Size\n\n```lua\npagination:SetItemsPerPage(24)\n```\n\nThe default page-size behavior is `PreserveFirstItem`, so the item at the beginning of the old page remains visible when possible.\n\n## Batching Changes\n\nWithout batching, every mutation processes the data and refreshes the view immediately. Use `Batch` when applying several related changes:\n\n```lua\npagination:Batch(function()\n\tpagination:SetSearchQuery(\"sword\")\n\n\tpagination:SetFilter(\"Owned\", {\n\t\tKeyPath = \"Owned\",\n\t\tOperator = \"Equals\",\n\t\tValue = true,\n\t})\n\n\tpagination:SetSortRules({\n\t\t{ KeyPath = \"Rarity\", Direction = \"Descending\" },\n\t\t{ KeyPath = \"Name\", Direction = \"Ascending\" },\n\t})\nend)\n```\n\nThis performs one final processing pass and emits one combined change context.\n\nYou can override the final page behavior:\n\n```lua\npagination:Batch(function()\n\t-- Mutations\nend, {\n\tPageBehavior = \"PreserveFirstItem\",\n})\n```\n\nManual batching is also available:\n\n```lua\npagination:BeginBatch()\npagination:SetSearchQuery(\"potion\")\npagination:SetFilterMode(\"All\")\npagination:EndBatch()\n```\n\nNested batches are supported. Only the outermost `EndBatch` applies pending work.\n\n## Stable Item Identity\n\nStable keys allow the handler to preserve item identity through filtering, sorting, page changes, and renderer reconciliation.\n\n```lua\nGetItemKey = function(item, sourceIndex)\n\treturn item.Id\nend\n```\n\nKeys must be unique and non-`nil` within the supplied dataset.\n\nWhen no selector is provided:\n\n- Tables and Instances use their reference as the key.\n- Primitive values use their source index.\n\nAn explicit selector is strongly recommended when items have a persistent ID, especially when `SetData` may supply newly created tables or reorder primitive values. Change the selector at runtime with `SetItemKeySelector`; the handler rebuilds source records and reprocesses the dataset:\n\n```lua\npagination:SetItemKeySelector(function(item, sourceIndex)\n\treturn item.Id\nend)\n```\n\nUseful key-based lookups include:\n\n```lua\nlocal item = pagination:GetItemByKey(itemId)\nlocal processedIndex = pagination:GetProcessedIndexByKey(itemId)\nlocal page = pagination:GetPageForKey(itemId)\n```\n\n`GetProcessedIndexByKey` and `GetPageForKey` return `nil` when the item is currently filtered or searched out.\n\n## Optional Imperative Renderer\n\nA renderer lets the handler reconcile view items by stable key. Rendering remains completely optional.\n\n```lua\nlocal pagination = PaginationHandler.new({\n\tItemsPerPage = 20,\n\tGetItemKey = function(item)\n\t\treturn item.Id\n\tend,\n\n\tRenderer = {\n\t\tCreate = function(entry, context)\n\t\t\tlocal frame = ItemTemplate:Clone()\n\t\t\tframe.Name = tostring(entry.Key)\n\t\t\tframe.LayoutOrder = entry.PageIndex\n\t\t\tframe.Parent = Container\n\n\t\t\tUpdateItemFrame(frame, entry.Item)\n\t\t\treturn frame\n\t\tend,\n\n\t\tUpdate = function(frame, entry, context)\n\t\t\tframe.LayoutOrder = entry.PageIndex\n\t\t\tUpdateItemFrame(frame, entry.Item)\n\t\tend,\n\n\t\tDestroy = function(frame, entry, context)\n\t\t\tframe:Destroy()\n\t\tend,\n\n\t\tCommit = function(entries, context)\n\t\t\tEmptyLabel.Visible = #entries == 0\n\t\tend,\n\t},\n})\n```\n\nOn each view refresh, the handler:\n\n1. destroys rendered keys that left the view;\n2. updates keys that remain;\n3. creates keys entering the view;\n4. calls `Commit` with the final ordered entries;\n5. fires `Rendered`.\n\nRenderer errors are caught and reported with `warn`, allowing pagination state to continue updating.\n\nProvide `Destroy` whenever `Create` allocates Instances, connections, observers, or other resources. Swap the active renderer at runtime with `SetRenderer` — the previous renderer's retained handles are destroyed first.\n\n## React, Fusion, and Other Declarative Frameworks\n\nYou do not need to use `Create`, `Update`, or `Destroy`. Subscribe to `ViewChanged` and push the entries into your framework's state:\n\n```lua\npagination.ViewChanged:Connect(function(entries, context)\n\titemsState:set(entries)\nend)\n```\n\nA commit-only renderer is another option:\n\n```lua\nRenderer = {\n\tCommit = function(entries, context)\n\t\titemsState:set(entries)\n\tend,\n}\n```\n\nThis keeps `PaginationHandler` responsible for data state while the UI framework remains responsible for component lifecycle and reconciliation.\n\n## Virtualization\n\nVirtualization limits the exposed view to an index window within the active page. It does not inspect GUI objects, calculate canvas sizes, or cross page boundaries.\n\nEnable and update the window directly:\n\n```lua\npagination:SetVirtualizationEnabled(true)\npagination:SetVirtualWindow(\n\t25, -- First visible item index within the active page\n\t12, -- Number of visible items\n\t4   -- Overscan items before and after the visible range\n)\n```\n\n`GetCurrentPageItems()` still returns the full page. `GetViewItems()` and rendering callbacks return only the virtualized range.\n\nFor uniformly sized lists or grids, calculate the window from scroll metrics:\n\n```lua\npagination:SetVirtualWindowFromMetrics(\n\tScrollingFrame.CanvasPosition.Y,\n\tScrollingFrame.AbsoluteWindowSize.Y,\n\t80, -- Item or row height\n\t8,  -- Spacing between rows\n\t4,  -- Items per row\n\t8   -- Overscan items\n)\n```\n\nCall this when the scroll position or viewport size changes.\n\nBy default, the virtual window resets to the start whenever the active page changes. Disable this with `Virtualization = { ResetOnPageChange = false }`.\n\nThe state exposes `ItemsBeforeView` and `ItemsAfterView`, which can be used by your UI layer to create spacers or calculate placement. The module does not create those elements automatically.\n\n## State and Signals\n\nRead a complete snapshot with:\n\n```lua\nlocal state = pagination:GetState()\n```\n\nAvailable fields:\n\n```lua\nstate.CurrentPage\nstate.PageCount\nstate.ItemsPerPage\nstate.TotalItemCount\nstate.ProcessedItemCount\nstate.CurrentPageItemCount\nstate.PageStartIndex\nstate.PageEndIndex\nstate.CanGoPrevious\nstate.CanGoNext\nstate.SearchQuery\nstate.FilterMode\nstate.ActiveFilter","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/biotoxin495/roblox/paginationhandler/1.0.0/readme"}