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

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. 200520050000); 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.

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

UI parity with wikipedia-pages-js

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