anilist-anime-js — AniList Anime (GraphQL)
A plain HTML/CSS/JS demo (like the other *-js samples; not Vite/Vue — that's
wikipedia-pages-vue) that renders AniList anime in
vanilla-grid via the built-in GraphQLDataManager.
It is the reference showcase for the GraphQL data manager: server-side sort,
free-text search, structured column filters, offset pagination, and a total
row count read inline from the same response (a count strategy OData can't do —
it needs a separate $count call).
Endpoint
https://graphql.anilist.co— public, no auth, permissive CORS,POST application/json.- Rate limit: ~90 requests/minute nominal (can be lower during degradation).
The built-in toolbar search item debounces input (600 ms) and the app surfaces
HTTP
429as a "rate limited" toolbar status. - Caps: a server-side
perPagecap of 50 (→pageSize: 50) and a total ceiling of ~5000 rows (the "of N" figure reflectspageInfo.total, which AniList caps).
The query (rows + inline total in one request)
query ($page: Int, $perPage: Int, $search: String, $sort: [MediaSort],
$id_in: [Int], $genre_in: [String], $format_in: [MediaFormat], $status: MediaStatus,
$seasonYear: Int, $startDate_greater: FuzzyDateInt, $startDate_lesser: FuzzyDateInt,
$averageScore: Int, $averageScore_greater: Int, $averageScore_lesser: Int) {
Page(page: $page, perPage: $perPage) {
pageInfo { total currentPage lastPage hasNextPage perPage }
media(type: ANIME, search: $search, sort: $sort, id_in: $id_in, genre_in: $genre_in,
format_in: $format_in, status: $status, seasonYear: $seasonYear,
startDate_greater: $startDate_greater, startDate_lesser: $startDate_lesser,
averageScore: $averageScore, averageScore_greater: $averageScore_greater,
averageScore_lesser: $averageScore_lesser) {
id
title { romaji english }
format status seasonYear startDate { year } episodes duration averageScore popularity genres
siteUrl description(asHtml: false) coverImage { medium large }
}
}
}
The siteUrl / description / coverImage fields are pure app chrome (title-cell
thumbnail + detail panel); the manager neither needs nor knows about them — it
only unwraps Page.media and reads Page.pageInfo.total.
Manager configuration
new window.GraphQLDataManager({
endpoint: 'https://graphql.anilist.co',
query: ANILIST_QUERY,
paginationStyle: 'offset',
pageSize: 50,
infiniteScroll: true,
dataPath: 'Page.media', // where the rows array lives in `data`
totalPath: 'Page.pageInfo.total', // inline total — NO separate count request
buildVariables, // page/perPage/search/sort + filter args
buildSort, // grid column → [MediaSort] enum
buildFilter, // filter model → AniList flat args
buildSearch: (term) => term || null,
onBeforeFetch, onFetchError, // status text only — the grid element sets
// its own loading skeleton around every load
});
The manager always thinks in grid-native skip/top; buildVariables maps that
to AniList's page-based API (page = Math.floor(skip / top) + 1, perPage = top)
via the convenience fields on the builder state object.
Pagination
Offset/page-based with an inline total. <vn-grid> auto-wires infinite scroll to
dm.fetchMoreRows(skip, pageSize); the manager derives page/perPage from
skip/top. Each response's Page.pageInfo.total is pushed to the grid, so the
scroll indicator shows "N of TOTAL".
Sort (server-side [MediaSort])
buildSort maps the grid column + direction to an AniList sort enum:
| Column | Enum (asc / desc) |
|---|---|
title |
TITLE_ROMAJI / TITLE_ROMAJI_DESC |
seasonYear |
START_DATE / START_DATE_DESC |
episodes |
EPISODES / EPISODES_DESC |
averageScore |
SCORE / SCORE_DESC |
popularity |
POPULARITY / POPULARITY_DESC |
Columns AniList can't sort server-side (format, status, duration, genres)
are declared sortable="false" in the markup.
Filters (structured column filters → AniList flat args)
buildFilter maps the grid's column-filter model to AniList's flat arguments.
AniList's genre/format/status fields are exact-match enums/tokens (there
is no substring filter server-side), so each filterable column declares a
filter-operators allow-list in the markup (with default-filter-operator
preselecting in on the token columns) — the filter panel then only ever offers
operators AniList can honor, and buildFilter receives nothing it can't map:
| Column | filter-operators |
AniList arg |
|---|---|---|
id |
equals / in |
id_in |
genres |
in (default) / equals |
genre_in |
format |
in (default) / equals |
format_in |
status |
equals |
status |
seasonYear |
equals |
seasonYear |
seasonYear |
greaterThan / greaterThanOrEqual / lessThan / lessThanOrEqual / between |
startDate_greater / startDate_lesser |
averageScore |
equals |
averageScore |
averageScore |
greaterThan / greaterThanOrEqual / lessThan |
averageScore_greater / averageScore_lesser |
AniList has no seasonYear_greater/_lesser, so year ranges map to
startDate_greater/_lesser, which take a FuzzyDateInt (YYYYMMDD with unknown
month/day → 0, e.g. 2005 → 20050000); buildFilter converts each year to
this boundary. startDate is the air date — equal to seasonYear for nearly all
titles but able to differ at a year boundary — so exact-year matches still use the
precise seasonYear arg. A title with an all-null (unknown) startDate passes
startDate_lesser but fails startDate_greater, so "less than" queries also pin a
minimal lower floor (startDate_greater: 1) to keep date-less titles out.
Year display fallback. Because the year filter and sort act on startDate,
but the Year column shows seasonYear, the two would disagree for upcoming
titles — AniList frequently gives a NOT_YET_RELEASED entry a known
startDate.year but a null seasonYear, so it passes a "year > N" filter yet
renders a blank year. To keep what you see aligned with what you filtered, the app
selects startDate { year } and the Year column's displayYear() shows
seasonYear ?? startDate.year (via renderCell — a valueGetter can't be used,
as the grid compiles value accessors at setColumns() time). The same fallback
feeds the detail panel and the Excel export.
Unknown-date titles sort first in both directions. Some titles
(cancelled or announced-but-undated projects) have a null seasonYear and an
all-null startDate — there is no year to show, and the Year column renders a
- placeholder for them. The AniList API emits these unknown-date titles at
the top of the results under both START_DATE and START_DATE_DESC (SQL
NULL ordering on their backend — nulls don't participate in the comparison;
only the tie-break flips, so ascending leads with old cancelled titles and
descending with freshly announced ones). A Year sort therefore legitimately
starts with a block of - rows in either direction; this is upstream API
behavior, not a display bug.
So the panel's zero-config default (e.g. Format is any of TV) filters to
format_in: ["TV"]. Operators AniList genuinely can't express (startsWith,
notContains, isEmpty, …) are never offered thanks to the allow-lists;
buildFilter keeps a console.warn fallthrough as a safety net for filters on
columns with no mapping at all.
Sorting and filtering are both server-side reloads driven by the same
auto-reload path: a header-sort runs handleSort() (updating sort:) and a
column filter runs setColumnFilters(), and each fires onConfigChanged. The
grid (setAutoReloadOnConfigChange(true)) coalesces those into a single re-issue
of the query from page 1 with the new args — no per-sort reload callback. The
manager's onSortChanged is a notification hook only.
Search
buildSearch is a passthrough (term || null); the term becomes AniList's
search: argument. Search is the built-in <vn-grid-toolbar-search delay="600">
toolbar item — it wires its own debounced input straight to <vn-grid>.search()
(→ setSearchTerm → AniList search:), so no host code is involved. The 600 ms
debounce keeps AniList's ~90 req/min rate limit comfortable.
The search presentation follows the active theme (mirrors northwind-orders-js):
modules/theme.js#switchTheme() toggles the search item's expandable attribute
— the Carbon-style collapsing lens is used only for the IBM Carbon light/dark
themes; every other theme renders the standard inline search box.
AniList search is whole-word, not substring/prefix. Its search: engine
matches complete title tokens (with some fuzzy tolerance for typos within a
word); it does not match arbitrary prefixes shorter than a full word. So
Hokuto finds Hokuto no Ken but Hoku/Hokut return zero results, and
Ken matches because it's a whole word in the title. This is server-side AniList
behavior, not a grid limitation — there's no host-side substring fallback because
the dataset is server-paginated (infinite scroll), so the unloaded rows aren't
available to match against locally.
Row shaping
title— a value getter flattenstitle { romaji english }to a scalar (romaji, falling back toenglish); a custom cell renderer adds the cover thumbnail.genres— a value getter joins the array with·for display/export. The genre filter's value list is user-entered, so the joined display text never leaks into the server-sidegenre_inargument.averageScoreis 0–100 (not 0–10) — noted in the column's secondary label.episodes,seasonYear,durationare frequentlynullfor ongoing/unreleased titles; the grid's empty-cell rendering handles it. For the Year column,seasonYearfalls back tostartDate.year(see Filters above) so upcoming titles that pass the startDate-based year filter still show a year.
UI parity with wikipedia-pages-js
- Theme selector — the 8 built-in grid themes, app-shell chrome
theme-parameterized via
themes/app-*.css(copied verbatim from wikipedia-pages-js). - EN/IT i18n — column headers, status labels, and the detail panel switch
language; the AniList
MediaStatusenum is localized in the detail panel (grid cells keep the raw enum so the server-side filters round-trip). - Detail panel — row double-click (or the toolbar
Viewcommand on a single selection) opens a side panel with the cover, description, metadata, and the AniList page link. - Toolbar — built-in debounced search, auto-fit, clear filters & sorting, reset, reload, theme-styled Excel export.
How the components are loaded
index.html loads the components as plain custom-element scripts (same tail as
wikipedia-pages-js):
<script src="../../src/vanilla-resize-box/vanilla-resize-box.js"></script>
<script src="../../src/vanilla-grid/vanilla-grid.js"></script>
<script src="../../src/vanilla-grid-toolbar/vanilla-grid-toolbar.js"></script>
<script type="module" src="app.js"></script>
vanilla-grid.js auto-loads its feature scripts (including
window.GraphQLDataManager) asynchronously; app.js awaits
window.VanillaGridReady before constructing the manager and grid.
Resizing the grid
The grid sits in a src/vanilla-resize-box/ (aria-label="Resizable AniList anime grid"). Drag the corner handle to resize it — resizing is pointer-driven, with no keyboard path.
The handle carries role="separator" and takes its accessible name from the host's aria-label — here "Resizable AniList anime grid — resize handle" — but is not focusable, so it adds no tab stop to the page. This app's markup needs nothing beyond the aria-label it already carries.
AniList-specific caveats
- No
edges/cursor — AniList is page-based, so this app usespaginationStyle: 'offset', not cursor. (The cursor path is still shipped inGraphQLDataManagerand covered by the mocked Playwright spec.) - Inline total — demonstrates the "total in the same response" strategy
(
totalPath), which OData can't do. - Errors envelope — a GraphQL error is an HTTP 200 with a populated
errorsarray; the manager throwsGraphQLDataManager.GraphQLErrorby default.