Skip to content

♻️ Replace DataTables with an in-place, dependency-free needtable enhancer - #1920

Merged
chrisjsewell merged 28 commits into
masterfrom
feat/needstable-enhancer
Sep 8, 2026
Merged

♻️ Replace DataTables with an in-place, dependency-free needtable enhancer#1920
chrisjsewell merged 28 commits into
masterfrom
feat/needstable-enhancer

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Sep 8, 2026

Copy link
Copy Markdown
Member

A needtable with the default :style: datatables is still interactive — sort a column, search the whole table, page through it, choose how many rows a page holds, switch columns off, copy it or download it as CSV — but it is now Sphinx-Needs' own script instead of a vendored copy of DataTables 1.10.16 from 2018, and it only loads on pages that actually have a table. The enhancement happens in place: every <tr> and <td> Sphinx-Needs rendered is still the same element afterwards, so :style_row: classes, the needs_<column> cell classes, the links inside cells and the <colgroup> all survive sorting and searching, and a reader with JavaScript off still gets the whole table. The script is deferred, so it never blocks the parser.

4.72 MB of vendored JavaScript in 34 files becomes 58.5 KB in three; a page with a table loads 55.9 KB (14.8 KB gzipped) instead of 2.29 MB (997 KB gzipped), and search.html, genindex.html and every page without a table now load none of it (closes #462 for the table assets).

Why not Grid.js, and why not DataTables 3

Grid.js — what #1464 proposed — reads td.innerHTML into a string[][], hides the source table and offers one class string for all rows, so :style_row: cannot survive it by construction; it has had no release since March 2024. DataTables 3 is jQuery-free, in-place and MIT, and was the honest fallback at about 67 KB gzipped; it was not chosen because it brings its own control DOM and CSS with fourteen !important rules that every host theme has to fight, it is a six-week-old major from a one-person project, and any third-party bundle needs a build toolchain and a manifest in this repository that a hand-written file does not. The full comparison — simple-datatables (LGPL-3.0), Tabulator, List.js, tablesort, TanStack — is the decision record at the top of packages/sphinx-needs/design/needstable-contract.md.

The claims in this description were re-measured in review on two independent corpora: 41.1 ms and 40.2 ms to initialise ten thousand rows against our 41.5 ms, and 10 000 of 10 000 rows, 60 000 of 60 000 cells and 6 of 6 <col> elements still the same DOM objects after five sort, filter and destroy cycles.

The markup is now a contract

<table> keeps NEEDS_DATATABLES as its hook and gains data-needstable-* options; every <th> gains scope="col", a data-col column key and, where Sphinx-Needs knows it, a data-type; every <tr> gains data-need-id, and a part row data-parent. :show_filters: now writes its paragraph after the table instead of between </tbody> and </table>, which was invalid HTML. The design document specifies all of it — the markup, the script's API and behaviours, the CSS custom properties a host answers, and the vendoring rule — so that any producer of needtable markup can emit against it. ubCode, useblocks' Rust-based tooling for needs projects, renders needtables from the same sources and will ship this asset unchanged against the same markup contract.

The script and its structural stylesheet, src/sphinx_needs/libs/html/needstable.{js,css}, are plain ES2020 JavaScript type-checked through JSDoc (// @ts-check; the one-command tsc recipe is in the design document) and host-agnostic CSS that asks for every colour through a --needstable-* custom property. Sphinx-Needs' own theme sheets answer those properties; a consumer without them gets currentColor fallbacks.

Two visible behaviour changes

The count under the table reads Showing 1–10 of 42 and counts needs, not rows: a need and its parts are one entry, so a page never splits a need from its parts, and a table with :show_parts: reports a smaller total than DataTables did. And a cell with no value now sorts last whichever way the column points, where DataTables sorted blanks first.

Removed, and what replaces them

  • Excel and PDF export → the CSV download (UTF-8 with a BOM, so Excel opens it directly) and the browser's own print command. They were about 900 KB of the payload for two buttons.
  • Column reordering → nothing. It was switched on, undocumented, and no issue ever mentioned it.
  • .dataTables_* / .dt-button CSS hooks → the needstable-* classes. A project that styled the old chrome has to re-point at the new one; a project that styled .NEEDS_DATATABLES, .need, .need_part or needs_<column> is unaffected.
  • The --sn-color-datatable-* theme variables--sn-color-table-{label,btn-border}.

Upgrading needs nothing else: this release changes four of the extension's own asset files, and they are now re-copied over a build directory that still holds the old ones. Sphinx would otherwise leave the stale stylesheet in place with a copy_overwrite warning — and fail a -W build — on the first incremental build after the upgrade.

Gained

  • a page-size control that includes All (a review finding on 🛠️ Refactor Sphinx-Needs DataTables integration to use GridJS #1464), configurable through needs_table_page_size, needs_table_page_sizes and a :page_size: option;
  • typed sorting that returns to the server's :sort: order on the third click, so the documented "sorting only works with :style: table" caveat is gone;
  • part rows that travel with their need through sorting and filtering;
  • aria-sort and keyboard-operable headers, an aria-live result count, focus that stays inside the pager when you page with the keyboard;
  • a scroll frame of the widget's own, so a wide table scrolls inside it instead of escaping the page, and a table that fits fills its column;
  • the columns control is a plain button in every theme we render, rather than whatever the theme does to a <summary>;
  • twenty-one browser tests and a markup-contract test module where there were none.

jQuery is still a dependency, because sphinx_needs_collapse.js (the need meta box, not the table) is still jQuery — porting it is the follow-up that lets sphinxcontrib-jquery go.

Render pass

The docs were built with furo, sphinx_rtd_theme, pydata-sphinx-theme, sphinx-immaterial and alabaster, in light and dark where the theme has one, before and after, with a new poe docs-needs-shots instrument (its own PR follows). The Read the Docs preview of this PR shows the furo build live.

Changelog draft

* :ref:`✨ Improvement <needtable>`: The ``datatables`` table style is now Sphinx-Needs' own,
  dependency-free enhancer instead of the bundled `DataTables <https://datatables.net>`__
  package. It sorts (typed, and back to ``:sort:`` order on the third click), searches, pages
  with a 10/25/50/All control, hides columns, copies and downloads CSV -- and it enhances the
  rendered table in place, so ``:style_row:``, the ``needs_<column>`` cell classes and the
  links inside cells survive every interaction. Need parts stay with their need. New:
  :ref:`needs_table_page_size`, :ref:`needs_table_page_sizes` and
  :ref:`page_size <needtable_page_size>`. (:pr:`1920`)
* :ref:`✨ Improvement <needtable>`: The table's script and stylesheet are added only to pages
  that contain a needtable; ``search.html`` and ``genindex.html`` no longer carry them.
  4.72 MB of vendored JavaScript becomes 58.5 KB. (:issue:`462`, :pr:`1920`)
* :ref:`👌 Improvement <needtable>`: The interactive table's count reads *Showing 1-10 of
  42* and counts **needs**: a need and its :ref:`parts <needtable_show_parts>` are one
  entry, so a page never splits a need from its parts. A table with ``:show_parts:``
  therefore reports a smaller total than the DataTables style did, which counted rows.
  Cells with no value now sort last whichever way the column points, where DataTables
  sorted them first. (:pr:`1920`)
* 💥 Removed: Excel and PDF export, and column reordering, from the ``datatables`` style. The
  CSV download opens in Excel and browsers print; column reordering was undocumented.
  Projects that styled ``.dataTables_*`` or ``.dt-button`` must re-point at the
  ``needstable-*`` classes, and ``--sn-color-datatable-*`` is now ``--sn-color-table-*``.
  (:pr:`1920`)

Closes #462.

Related issues, and what this changes for each

  • 🛠️ Refactor Sphinx-Needs DataTables integration to use GridJS #1464 — superseded by this PR; the size measurement, the deletion of the dark-theme DataTables block and the 10/25/50/All requirement were kept from it.
  • DataTable config per needtable #408 (per-table DataTables configuration) — the requested shape, a dict of raw DataTables options, no longer applies. Per-table settings now travel as data-needstable-* attributes emitted from directive options and config (page size and page sizes ship here); more knobs follow the same route. Note that the side extension posted there in August reconfigures DataTables and stops working with this change, and PDF export is replaced by CSV plus the browser's print dialog.
  • needtable layout options #1425 (named table layouts) — orthogonal server-side presets; nothing in the contract stands in their way. Follow-up.
  • Datatable not displaying properly with Read the Docs theme #771 (Read the Docs theme) — the collision was between DataTables' wrapper and the theme's own table wrapper; the DataTables wrapper is gone and the widget's scroll frame is the container, with the theme's wrapper nesting inside it. Measured correct on the docs build; a plain project on that theme still wants confirmation, because these docs carry a small RTD override of their own.
  • Support sorting needtable with order of apperance in the document #1144 (order of appearance) — :sort: lineno already answers it, but only worked with :style: table because DataTables re-sorted at load. The interactive table now keeps the server's :sort: order as its initial and "unsorted" state, so it works in the default style too. Multiple :sort: keys remain a follow-up.

In short: issues and follow-ups

after this PR
#462 closed for the table assets; the shared host stylesheet is still global — follow-up
#1464 superseded; close with the measurement that Grid.js re-renders from cell text
#1144 :sort: lineno now works in the default style; close after release, multi-key :sort: stays open
#771 DataTables' wrapper gone, frame + theme wrapper nest cleanly on the docs build; confirm on a plain RTD project, then close
#408 answered by data-needstable-* attributes rather than a DataTables option dict; the August side extension there stops working — needs a heads-up
#1425 orthogonal server-side presets; unaffected, follow-up

Follow-up work, none blocking: port sphinx_needs_collapse.js off jQuery and drop sphinxcontrib-jquery; gate the shared host stylesheet per page; type-check the shared asset in CI; a sticky header now that the frame is an explicit box; a caption-derived CSV file name; logical instead of physical padding for the sort glyph under RTL; and, on request, column reorder, multi-column sort, responsive collapse, URL state or translation catalogues inside the script.

@chrisjsewell chrisjsewell added enhancement pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs labels Sep 8, 2026
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.52941% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.03%. Comparing base (eb23d5e) to head (d367bc1).
⚠️ Report is 21 commits behind head on master.

Files with missing lines Patch % Lines
...inx-needs/src/sphinx_needs/directives/needtable.py 98.94% 1 Missing ⚠️
...kages/sphinx-needs/src/sphinx_needs/environment.py 96.15% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1920      +/-   ##
==========================================
+ Coverage   91.84%   92.03%   +0.19%     
==========================================
  Files         105      105              
  Lines       16231    16279      +48     
==========================================
+ Hits        14907    14983      +76     
+ Misses       1324     1296      -28     
Flag Coverage Δ
codelinks 93.42% <ø> (ø)
mounts 94.01% <ø> (ø)
pytests 91.40% <98.52%> (+0.27%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@chrisjsewell
chrisjsewell marked this pull request as ready for review September 8, 2026 16:51
@chrisjsewell
chrisjsewell requested a review from ubmarco September 8, 2026 17:03
@chrisjsewell
chrisjsewell force-pushed the feat/needstable-enhancer branch from 68f9ca8 to 8a18bde Compare September 8, 2026 17:09
Sphinx-Needs and ubCode both render needtables, and the client-side table
enhancer that is about to replace DataTables has to work against both. This
commit adds the markup half of the shared contract to Sphinx-Needs.

The `<table>` keeps `NEEDS_DATATABLES` as its selector and gains the per-table
options `data-needstable-page-size` / `-page-sizes` (still literals here; the
configuration that feeds them lands with the options). Each `<th>` gains
`scope="col"`, a `data-col` column key and, where the field schema says so, a
`data-type`. Each `<tr>` gains `data-need-id`, and a part row `data-parent`, so
that a sorter can keep parts under the need they belong to. The visible header
text, the cell classes, the links and the `<colgroup>` are unchanged.

`:show_filters:` now writes its paragraph AFTER the table instead of between
`</tbody>` and `</table>`, which was invalid HTML that browsers hoist out again
and that any enhancer trips on. Order is table, filter paragraph, max-items note.

docutils' HTML writer serialises only what a visitor hands to `starttag()`, so
the three pieces of markup are node sub-classes with HTML visitors that delegate
to the base ones with the extra attributes injected. No other builder gets a
visitor, so `SphinxTranslator.dispatch_visit` walks the MRO and latex, text,
texinfo, man and singlehtml render them as the `table`/`row`/`entry` they derive
from -- asserted for latex and singlehtml.

ENV_DATA_VERSION 7 -> 8: the table is built at `doctree-resolved`, so without a
bump a rebuild over an existing `_build` writes no page whose source did not
change and leaves the old markup on disk.
`libs/html/needstable.js` and `libs/html/needstable.css` are the pair that
replaces DataTables. The script sorts, searches, pages, hides columns and
exports, and it does all of it by MOVING the rows the server rendered, never by
re-rendering from extracted cell values -- which is what keeps `:style_row:`
classes, the per-cell `needs_*` classes, the links inside cells and the
`<colgroup>` alive through every interaction. It has no dependencies, uses no
`innerHTML` and no `eval`, fetches nothing, and runs from `file://` and under a
strict CSP.

Two behaviours are worth naming because neither incumbent had them: a need and
its `need_part` rows are ONE unit, so a sort keeps parts under their need and a
filter that matches a part shows its need; and there is no sort at
initialisation, so the server's `:sort:` order IS the unsorted state and the
third click on a header restores it exactly.

Only the current page's rows are attached to the document, as DataTables also
did -- that is what keeps a very large table usable.

`needstable.css` is the STRUCTURAL half and is host-agnostic: it names no theme
and no `--sn-*` token, and takes colour only through `--needstable-*` custom
properties with `currentColor`/`inherit` fallbacks. sphinx-needs maps its own
tokens onto them in a later commit.

`design/needstable-contract.md` is the specification both files implement.
sphinx-needs is the repository of record; ubCode will vendor the pair
byte-identical behind a sha256 fence.

Ten browser cases in `tests/test_needstable_js.py` drive it over `file://` in
the same shape as the collapse-button tests, against a new test project with
twelve needs, parts, a numeric field, an ISO-date field, links, `:colwidths:`,
`:show_filters:` and a second `:style: table` table the script must not touch.
4.72 MB and 33 files go: DataTables 1.10.16, Buttons, ColReorder, FixedColumns,
FixedHeader, Responsive, Scroller, JSZip, a second copy of pdfmake, a Flash
export shim, and the 376-byte loader that drove them. All of it landed in one
commit in 2018 and was never updated -- half of it was never even loaded, and
no dependency ecosystem could see any of it. What replaces it is 38 KB of
hand-written source, 10 KB gzipped, that ships with the package and is versioned
with it.

The CSS follows the same boundary the pair does. `css/common/needstable.css`
keeps the theme-neutral rules (and the doubled-class specificity trick, with the
comment explaining it), adds the scroll behaviour for the wrapped table and maps
sphinx-needs' tokens onto the pair's `--needstable-*` properties. The ~228 lines
of DataTables-selector overrides go: `dark.css`'s 100-line block (which carried
a "TODO this should not be part of the theme" of its own), the `.dataTables_*`
and `.dt-button` rules in `sphinx_immaterial.css`, and the `div.dataTables_wrapper`
margin in `sphinx_rtd_theme.css`.

`--sn-color-datatable-{label,btn-border}` become `--sn-color-table-*`, in the
three themes and in the four docs theme sheets; `--sn-color-datatable-body-bg`
goes entirely -- it existed to undo a DataTables row background and was
`transparent` in every theme.

`sphinx_rtd_theme.css` is kept rather than deleted: its other block,
`div.wy-table-responsive { width: 100% }`, repairs the RTD theme's own
table-width collision (#771) and has nothing to do with the table library.

jQuery stays a dependency: `sphinx_needs_collapse.js`, which is about the need
meta box and not about needtables, is still jQuery. Its comment in
`pyproject.toml` now says so.
Closes #462 for the table assets. Since 2022 every page of every sphinx-needs
project has carried the table's script and stylesheet, `search.html` and
`genindex.html` included -- pages that have no doctree at all, and until the
previous commit that meant 2.26 MB of DataTables on each of them.

`process_needtables` marks the resolved doctree when it emits an interactive
table, and an `html-page-context` handler registers the pair for that page.
The doctree the handler is given is the very object the directive processing
mutated moments earlier, in the same process, for serial and parallel writes
alike -- so no set of document names has to be stored in the environment,
purged when a document changes, or merged across parallel readers, and an
incremental build cannot serve a page whose marker went stale.

Registration goes through the BUILDER rather than `app.add_js_file`: the
application's route also appends to the extension registry, once per page, and
a builder re-initialised afterwards would pick every one of them up globally --
which is the behaviour this handler exists to remove. `handle_page` resets the
builder's asset lists just before it emits the event, so the addition reaches
that page and no other.

`common_css/needstable.css`, the host half of the sheet, is still global: it
comes from the deterministic `common_css/*` loop, and gating that loop is the
rest of #462.
`needs_table_page_size` (default 10) and `needs_table_page_sizes` (default
[10, 25, 50, 0], where 0 means "All") set what the interactive table's page-size
control offers and which of them it starts on; `:page_size:` overrides the first
for one table. They reach the browser as `data-needstable-*` attributes on the
`<table>`, which is the extension point the contract reserves for exactly this
-- and the shape #408 and #1425 want, without a DataTables-shaped configuration
dictionary.

Both are validated: a page size is a positive whole number (0 means "all rows",
which is not a page size), and the offered sizes are a non-empty list of whole
numbers that are not negative. A bad configuration value is reported once, at
`config-inited`, with the default it fell back to; a bad `:page_size:` is
reported at the directive that carries it. Neither stops a build.

The docs say what the interactive table does and, as importantly, what it no
longer does: Excel and PDF export and column reordering are gone, and the CSV
download and the browser's print command are the answers. The note claiming that
`:sort:` only works with `:style: table` was true of DataTables, which re-sorted
by column one on load; it is replaced by what is now true -- `:sort:` is the
order the reader first sees in both styles, and the one a third click on a
header returns to.

`tutorial.rst` said `:style: datatable`, which is not a value; it fell back to
the configured default and rendered as intended by accident.
Two things a future reader of `needstable.js` will want and could not get from
the code.

**Why this file exists at all.** `design/needstable-contract.md` gains a decision
record as its first section: the problem as it was measured (2.26 MB and jQuery
on every page, a 2018 vendoring no ecosystem could watch, half the tree
unreachable, ~228 lines of override CSS, no behavioural test), the property that
decided it (enhance the server-rendered DOM; a `string[][]` data model cannot
carry a row class, a cell class, a link or a `<colgroup>`), every alternative
that was looked at with its one-line measured reason, why one hand-written file
rather than a dependency, and what was given up in exchange.

**What the types are.** The script stays plain ES2020 JavaScript -- one artefact,
no build step, readable in the tree and in `view-source`, and vendored verbatim
-- but it now starts with `// @ts-check` and carries JSDoc types for the public
API and every non-trivial internal. One command checks them, with no
`package.json` and no node in CI:

    npx -y -p typescript tsc --allowJs --checkJs --noEmit --strict \
        --target es2020 --lib dom,es2020 \
        packages/sphinx-needs/src/sphinx_needs/libs/html/needstable.js

It exits 0. The recipe and the reason not to write TypeScript source are in the
design document.

Typing it turned the constructor-function into a class and `var` into
`const`/`let`; the behaviour is unchanged and the ten browser cases still pass.

Two negative amounts join the browser test project, because `Intl.Collator` with
`numeric: true` already orders plain positive integers correctly -- it is only
wrong from "-5" against "-20" onwards. Without them the typed number comparator
was not actually load-bearing in the test.
…pty pager

Two defects review reproduced, each one line and each with the assertion that
would have caught it.

**The script was not deferred.** `install_needtable_assets` passed
`loading_method="defer"` to the BUILDER's `add_js_file`, but only
`Sphinx.add_js_file` translates that keyword; the builder writes every keyword
straight into the tag. So every needtable page of every project shipped
`<script loading_method="defer" src="…">` -- an attribute HTML does not have,
on a render-blocking script in `<head>`, which is the opposite of what was
asked for. It is now `defer="defer"`, asserted twice: in
`test_html_head_files`, and in `test_assets_are_registered_per_page`, which
unlike the former is not skipped on Windows. Both read the element now, not
just its `src`, which is why nothing could see this before.

**The pager's `hidden` attribute did nothing.** `nav.needstable-pager` carries
an author `display: flex`, which beats the user agent's
`[hidden] { display: none }` -- and that rule is the whole of what the attribute
does. Nothing leaked visually, because the element is also emptied, but it stayed
in the accessibility tree: a screen reader met an empty "Pagination" landmark on
every single-page table. One rule fixes it, and the browser test now asserts the
computed `display` rather than a zero bounding box, which passed either way.

Two committed sentences that described the deferred tag are corrected in place,
in the design document and in the browser test's fixture comment, each saying
what was claimed and what review measured.
…width back

Review measured that making the `<table>` itself the horizontal scroll container
costs it up to 22 % of the space it is given, and that the rule doing it is dead
code in the one theme where scrolling actually matters.

A `<table>` set to `display: block` is a block box whose rows are re-wrapped in
an anonymous table box, and that box shrink-to-fits: `width: 100%` reaches the
outer block and stops. Measured on a real docs build, one widget rendered 555 px
inside a 708 px column, another 667 px, and `:colwidths:` percentages resolved
against the shrunken width rather than the column. Before this branch the same
tables were `display: table; width: 100%`, so this was a regression, not a
trade-off. Separately, a host theme whose own script wraps every `<table>` in a
`<div>` displaces `div.needstable > table` entirely, so the rule never matched
there at all -- and where it did match, the docs' own
`display: table !important` beat it and `overflow` does not apply to a
`display: table` box anyway, so the scroll never armed.

The widget now has a third child, `div.needstable-scroll`, between the control
bar and the footer; the table lives inside it and keeps `display: table;
width: 100%`. The controls are siblings of the frame, so they still do not
scroll with the data -- which was the reason the wrapper was rejected as the
scroll container, and it does not apply to an inner box. `destroy()` needed no
change: it moves the table out and removes the wrapper, frame and all.

Measured after the change, sphinx-immaterial (the theme with the `!important`):
a table that fits is 654 px in a 654 px frame, and the wide one reports
`scrollWidth` 1301 against `clientWidth` 654 -- it scrolls inside the widget
instead of escaping it. The `!important` no longer fights anything, because the
table is `display: table` by design now.

A new browser case pins both halves: a table that fits fills its frame to within
a pixel, and a deliberately wide one (a new `wide.rst`, ten columns of
unbreakable text) scrolls inside the frame while the control bar keeps the
wrapper's width. The design document's DOM diagram gains the box, and the
sentence that named the table as the scroll container is corrected in place.
Putting the contract attributes on the `<table>`, the `<tr>`s and the `<th>`s
means wrapping the HTML translator's `starttag` for one call. The `finally`
clause removed the instance attribute unconditionally -- so if anything else had
put one there, it was gone from the first needtable onwards, for the rest of the
document, including on tables that have nothing to do with needs.

"Anything else" includes the very technique this code is using. Review
constructed it: a second extension that patches `starttag` on the translator
instance marks every start tag it writes; on a page with a needtable in the
middle, everything before the table carried the marker and everything after it
did not. The failure is silent and produces inconsistent output rather than an
exception, because the needtable's own wrapper had already captured the patched
callable and delegated to it.

It now reads what the instance carried before -- with a sentinel, because "no
attribute" and "an attribute whose value is None" are different -- and puts
exactly that back.

The test is the repro: a project whose own second extension installs the patch
from a `visit_` handler, and an assertion that the paragraphs on BOTH sides of
the needtable carry its attribute, and that the needtable's own attributes came
through that extension's wrapper at the same time.
`paintPager` rebuilds every button, so the button the reader just activated is
removed from the document while it holds focus. Measured: after "Next" the
active element is `<body>`, and a keyboard reader has to Tab from the top of the
document again on every page change. The widget is otherwise carefully
keyboard-operable -- real buttons, `aria-sort`, an `aria-live` count -- which is
what made this the one place the care ran out. The header buttons are not
rebuilt, so the existing keyboard test could not see it.

Where focus is now read BEFORE the pager is emptied, and given back afterwards
to the equivalent control: the same page number if it is still offered, else the
nearest one; a previous/next that has become disabled on the new page hands
focus to the other one, which is what happens when you page to the end.

The keyboard test now activates "Next" from the keyboard and asserts focus is
still inside the pager -- and, on the last page, that it landed on "Previous".
…rk page

`needstable.css` asks for six colours through custom properties; the sphinx-needs
host sheet answered three. The two it did not answer are the columns
disclosure's, and their structural fallbacks are the system colours
`Canvas`/`CanvasText`, which follow the USER AGENT's colour scheme rather than
the page's. Measured in furo with the site's own dark switch on: the page is
`rgb(19, 20, 22)` and the popover is `rgb(255, 255, 255)` with black text.

It is the only place this could show, because the popover is the only opaque
surface the widget paints -- everything else is transparent and inherits the
page, which is why the search box and the page-size select already look right in
dark mode. It is also invisible to anyone testing with a dark OS, where the
system colours happen to come out right.

`--sn-color-table-popover-bg` / `-fg` join the three themes and are mapped in the
host sheet; the docs' per-theme sheets, which already re-point the other table
tokens, re-point these at each theme's own mode-aware background and text
colours, so the sphinx-needs docs are right in both modes of all three.

The browser test builds the project with `needs_css = "dark.css"` and asserts the
open popover's computed colours are the theme's, not the user agent's. The design
document's CSS contract now says a host MUST answer the pair, and why.
…e sort glyph

A theme gallery across five themes, light and dark, found the widget inheriting
things it should not and drawing one thing badly.

**The `Columns` control was an admonition.** It is a `<details>`, and a host
theme may style every `<summary>` on the page as a collapsible admonition: a
pale fill, a coloured border, an accent bar, bold text, an injected pencil icon
and a chevron. Measured at 141 px wide against 76-82 px in the four themes that
leave `<summary>` alone -- the extra 60 px is the injected icons -- and it was
the only coloured object in a toolbar of plain-bordered buttons. It now states
its own `font`, `background`, `box-shadow` and `border`, and suppresses the
marker and any injected `::before` / `::after`.

**The search box and the page-size select ignored the host's font, then took
it.** The buttons carry `font: inherit`; the two form controls did not, so a
theme's compounding `font-size` cascade landed on them alone: 10.14 px against
14.08 px buttons in one theme, and 22 px against a 31 px input in another -- two
controls on the same line, nine pixels apart in height. Both now take
`font: inherit` as well.

**The at-rest sort indicator read as an hourglass.** One element carrying both a
`border-top` and a `border-bottom` triangle makes them meet at a point, which at
14 px is a solid shape that says "loading" rather than "sortable either way". It
is now two absolutely positioned triangles with a 2 px gap -- still borders, still
`currentColor`, still no glyph and no image -- with the up one above the down one,
and the sorted states show one of the pair at full strength without moving the
header text.

The browser test gains a cheap guard that every control on the bar computes to
one font size; the real verification for all of this is the gallery.
…ract

The CSS contract now says the sheet has to fence the widget off from the host's
bare element selectors, not only take colour from it, and that the at-rest sort
indicator is two triangles with a gap. It also repairs a paragraph break the
popover correction ran into.
Review's coverage map found seven behaviours the design document publishes as
contract -- things another producer implements against -- with no exerciser
anywhere in the tree, so a mutation that removed each of them left the whole
suite green. Six of them are pinned here (the seventh, the deferred script tag,
was pinned with its fix).

- `<td data-sort>` overrides a cell's sort value, and
  `data-needstable-labels` overrides the English strings. sphinx-needs emits
  neither today, which is exactly why the prose was the only thing holding them
  up. Both are now set on a built page and the widget re-initialised over it.
- The export honours column visibility for PART rows as for leads: the same
  index filter runs over every row of every matching group, not only the leads.
- The info element is an `aria-live="polite"` region and the pager carries its
  accessible name, so the count is announced rather than merely displayed.
- A page size the producer names that is not among the offered sizes is added to
  the control. The test project's small table asks for one, so the assertion is
  on the real thing.
- The three things a truncated, filtered table emits come out in a fixed order --
  the table, then what selected it, then what was left out -- in a project of its
  own, because the `max_items` warning is the point of it.

And one decision made explicit rather than left as an accident: for
`:style: table` the filter paragraph now follows the wrapper `div` rather than
sitting inside the `<table>`, which is right (the paragraph is not table
content) and is now asserted for both styles.
…ly do

The design document is the deliverable, and review read it against the code.
Where the two disagreed, the code was right and the prose was wrong; where the
code was surprising, it now says so.

Corrected in place: `data-col` is the option name **lower-cased**, not "as the
author wrote it" -- a producer implementing the contract has to know the key is
normalised, and that it goes into `needs_col_<key>` verbatim, so a key that is
not a CSS identifier needs escaping in a selector.

Newly stated, because none of it was: a full stop is always the decimal mark and
a comma groups digits only in the `1,234,567` shape, so `1.000` sorts as one; a
bare `YYYY-MM-DD` is read as UTC and any other spelling in the reader's timezone;
the filter index is a snapshot taken at initialisation, and a page that mutates a
cell afterwards has to re-initialise for the filter to see it; exactly one header
row is read, the last of the `<thead>`; and a `:style: table` table carries the
row and header attributes but none of the table-level options. Two sentences of
the number and date rules go in `needtable.rst`, where a reader who is sorting a
column will meet them.

One real fix came out of the same reading: the filter index collapses runs of
whitespace but the query only had its ends trimmed, so a cell rendered with two
spaces was found by typing one and NOT by typing what is on the screen. Both are
normalised the same way now, with a test.

Two kindnesses while here. The last column still showing keeps its checkbox,
disabled -- hiding every column produced an empty grid, an info line still
counting rows and a CSV of empty records. And Escape closes the columns
disclosure and hands focus back to the summary that opened it.
The two assertions the `defer` fix was supposed to arrive with never landed --
a scripted edit failed on its first file and the two that followed it were
skipped, and nothing said so because the tag was correct. The mutation that
reinstates `loading_method="defer"` found it: both tests stayed green against a
page carrying an attribute HTML does not have.

They are here now, one in `test_html_head_files` and one in
`test_assets_are_registered_per_page`, which unlike the former is not skipped on
Windows. Both read the element's attributes and fail loudly with them.
docutils' own node classes are lower case (`paragraph`, `table`, `row`), and
the test extension follows them; ruff's CapWords rule does not know that.
`font: inherit` on the form controls was not enough on its own, for two reasons
a render pass across four themes turned up.

A host may set a `font-size` on bare `label`, and the control inside the label
then faithfully inherits it -- so the labels take the bar's size explicitly now.
The `font` shorthand also resets `line-height` to `normal`, which the control
inside inherits in turn, leaving it a couple of pixels shorter than the buttons
beside it; the labels state `line-height` too.

And one host sets `body, input { font-size: 16px !important }`, which nothing
weaker can answer. That is the single `!important` in this sheet and it is
documented where it stands, as the rule for this sheet requires.

Measured on real docs builds, the four controls' computed font sizes:

  furo               14.08 / 14.08 / 14.08 / 14.08
  sphinx_rtd_theme   14.08 / 14.08 / 14.08 / 14.08   (was 14.08 / - / 10.14 / 10.14)
  sphinx_immaterial  14.08 / 14 / 14.08 / 14.08      (was - / - / 16 / 13.33)

with their heights within three pixels of each other, against nine before.
The scroll frame that fix round 1 introduced left the table's `width: 100%`
where it already was -- in `css/common/needstable.css`, sphinx-needs' own host
sheet. The shipped pair is `libs/html/needstable.{js,css}`, which the contract
says a consumer vendors byte-identical, and on its own it carried no width rule
at all: measured on a page loading only the pair, the table lays out at 378 px
in an 800 px frame, with the `<colgroup>` percentages resolving against the
shrunken width. That is the finding the scroll frame exists to fix, reproduced
in the artefact the fix was written for.

Every existing test passed either way, because sphinx-needs supplies the rule
from the host side. What can fence the structural half is a page that loads
nothing else, so there is one now: `tests/fixtures/needstable_pair.html` carries
the markup contract and links the two files straight out of the package tree, and
the browser test opens it over `file://` and asserts the table fills its frame and
the control bar the container. It needs no sphinx build.

The contract now states the boundary rather than implying it: the structural
sheet is sufficient for layout on its own, and a host sheet answers the colour
tokens and nothing more.
The contract and the docs both say "a table cannot be reduced to no columns at
all", and `checkbox.disabled` was only ever assigned when the visibility
changed -- which for two or more columns means the reader's first click engages
the guard in time. With exactly one column that click is already the fatal one:
the reader empties the table, the CSV button then hands over a file of blank
records, and the checkbox stays enabled so nothing explains why.

One line: apply the visibility once at the end of `buildControls()`. The browser
project gains a one-column page, and the test asserts the flags at init on both
-- nothing disabled where there are columns to spare, the single one disabled
where there are not -- and that clicking the guarded box changes nothing.
`paintPager` reads where focus is before it empties the pager, but the
`pageCount <= 1` branch returned before ever restoring it -- so the one case
where the pager does not come back is the one case where focus was still lost.
It is the failure the focus restore exists to prevent, in the branch the restore
does not cover.

Its practical reach is small: every control that can collapse the table to one
page (the search box, the page-size select) takes focus itself when a human uses
it, so a keyboard reader cannot be in the pager when it happens. A script can
be, and so can any future control that re-filters without focusing itself.

Focus goes to the search box -- the control that collapses the table in
practice. The keyboard test drives the case programmatically and asserts both
that the pager really went to `display: none` and where the reader ended up.
The gallery's remaining cell: in one theme the `Columns` control was drawn with
a blue 1 px ring, because the fence named `details.needstable-columns > summary`
and the host styles the `<details>` element itself -- a border, a background and
a margin around the whole control, two pixels larger than the button inside it,
while `Copy` and `CSV` beside it were plain.

The fence now reaches both. Two things it turned up that the ring hid:

- with the host's padding removed the disclosure shrank to 53 px around a 77 px
  summary, because it is a flex item on the control bar -- hence `flex: 0 0 auto`
  and `min-width: max-content`;
- and the remaining 24 px of that gap was the host's **negative** horizontal
  margin on `summary` (`margin: 0 -.6rem`), which makes the control wider than
  the box around it. The summary states `margin: 0` now.

Measured after, in the theme that had the ring: `<details>` and `<summary>` both
77 × 26 with no border and no background, beside a 55 × 26 `Copy` button.
…ctory

Everything this extension copies into `_static/sphinx-needs/` is its own, never
anything a project wrote -- so when the file on disk differs from the file in
the package, the package is right. Sphinx has not agreed by default, and has not
disagreed the same way for long: up to 7.4 `copy_asset`/`copy_asset_file` take a
private `__overwrite_warning__` and copy anyway; from 8.1 they take `force` and,
when the destination exists with different bytes, warn `misc.copy_overwrite` and
**abort the copy**.

The consequence lands on this branch harder than on any release before it,
because this branch changes four shipped asset files. An upgrader's first
incremental build -- or anyone's build after a `git checkout` between commits --
keeps the OLD stylesheet on disk while the source has moved, so the widget runs
on its fallback colours, and under `-W` the build fails in four lines that
explain none of that. The theme-gallery run met it and produced a set of
screenshots of the previous commit's CSS; a reviewer met it on their first
command.

The fix asks the signature rather than the version number -- `force` if
`copy_asset` takes it, nothing if it does not -- so the 7.4 floor is passed
nothing it cannot understand and a release that spells it differently again
resolves itself. It is splatted into all four call sites this module has.

Measured: a `docs-needs` over a build directory whose two `needstable.css` files
had been replaced with different bytes now exits 0, emits no `copy_overwrite`,
and leaves both files matching their sources. The test does the same thing to
the three call sites in miniature: build, overwrite the three built files,
build again on the same outdir without `-E`, assert no warning and no stale byte.
…patch case

Two claims nothing was holding up.

**The default theme's popover tokens.** The popover test built with
`needs_css = "dark.css"`, so deleting `--sn-color-table-popover-*` from
`modern.css` -- the theme every project that never sets `needs_css` gets -- left
the whole browser lane green while silently restoring the white-on-dark defect
for the default. The test is parametrised over both themes now, each with its
own expected pair.

**The sentinel's stated reason.** Its docstring said it distinguishes "no
instance attribute" from "an attribute whose value is `None`"; nothing set the
latter, and the second half is not what the code is for. It now says what it
does and what is tested: restoring means putting back exactly what was there --
another extension's patch, or nothing -- and those need different operations, so
"nothing" needs a value that cannot be confused with an attribute that is
present.

The test extension gains a second patch installed further down the page, with a
second needtable after it, so the either-order case is exercised: one extension
already patching when the first table is written, two by the time the second is,
and both markers on everything that follows.
The round that removed two prose "9.0.0" claims introduced a third, in the
sentence explaining that the count is of needs rather than rows. It now says
"than DataTables did", which is the wording that cannot go stale and the one the
PR body already used. `git grep 9.0.0` over `docs/`, `design/` and `src/` now
finds five hits and all five are `versionadded::` / `versionchanged::`
directives, which stay by project precedent.

And the contract records what review measured about the scroll frame in a host
whose own script wraps every table: the theme's container lands inside the frame,
takes the overflow, and the frame never scrolls -- harmless, and the property the
frame was chosen for, since a box the host does not know about cannot be
displaced by the host wrapping the table. The `overflow-y: auto` that
`overflow-x: auto` implies is noted with it.
The rule that gives the table its width shipped as a child combinator,
`div.needstable-scroll > table` -- the same shape whose fragility the paragraph
three lines above it is about. A host theme's own script that wraps every table
in a container of its own puts that container between the frame and the table,
and the rule stops matching: measured on the pair-only fixture, 378 px in an
800 px frame, which is the number this section's previous correction quotes as
the defect it fixed.

sphinx-needs is unaffected -- its host sheet's rule names no parent -- so
nothing a project renders changes, and no test could see it. What was wrong is
the shipped artefact and the sentence beside it: the contract claimed the
structural sheet is sufficient on its own, and separately that a box the host
does not know about cannot be displaced by the host wrapping the table. The
frame indeed cannot; the rule that sized the table could.

It is a descendant selector qualified by the hook class now --
`div.needstable-scroll table.NEEDS_DATATABLES` -- qualified rather than bare so
that a table nested inside a needtable's own cell is not caught. The pair-only
fixture is measured in both shapes on one page load: as authored, and after a
five-line script wraps the table the way a theme's would. 800 / 800 in both.
…iewport

The branch that hands focus to the search box when the pager collapses is
conditional on something having had focus in the pager, and nothing tested the
condition. Review made it unconditional and the whole browser lane stayed green
-- while the page under test focused its search box on load and scrolled the
reader 2,820 px down the document to reach it. Any page whose second or third
table happens to fit on one page would have done that to a reader.

Two assertions in the case that already opens the page and touches nothing:
after load the active element is `<body>` and `window.scrollY` is 0. The test
project's second table is a single page, so the branch really does run there.
The block that fences the columns disclosure off from a host theme had nothing
automated behind it: review deleted the whole of it and the browser lane stayed
green, because the only theme the suite builds is a polite one. The gallery is a
human read, and the measured claim -- that `<details>` and `<summary>` agree in
width in every theme -- had nothing holding it.

The browser project now loads a stylesheet that does what the real themes were
measured doing: every `<details>` painted with a border, a background and a
shadow; a glyph injected into every `<summary>` through `::before` and
`::after`; the summary pulled out of its own box by a negative margin; a
22 px summary; and a 9 px font on form controls beside 14 px buttons.

The test asserts the computed values inside the widget -- no border, no
background, no box-shadow, no injected content, no margin, the two boxes equal
within a pixel, and one font size across the button, the summary, the input and
the select. It first paints a bare `<details>` on the same page and asserts it
comes out red, so a passing run means the fence held rather than that the
hostile sheet failed to load.
@chrisjsewell
chrisjsewell force-pushed the feat/needstable-enhancer branch from 8a18bde to d367bc1 Compare September 8, 2026 17:17

@ubmarco ubmarco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you test the new solution with sphinx simplepdf?

@chrisjsewell

Copy link
Copy Markdown
Member Author

Did you test the new solution with sphinx simplepdf?

Not before you asked — now yes, and it needs no change on either side.

What I measured. sphinx-simplepdf's own demo/ (which has a :style: datatables needtable with :colwidths: next to a plain one), built with -b simplepdf twice into scratch environments: sphinx-needs 8.5.0 from PyPI, and this branch. Same sphinx 9.1.0, same WeasyPrint 67.0, same simplepdf checkout at 847e1f9.

  • Both builds exit 0 with the same four warnings, all the demo's own (a citation, a footnote, an import key).
  • Both PDFs are 80 pages. The extracted text is identical apart from the demo's debug page, which prints the build path and dependency versions.
  • The two pages that hold the needtables (53 and 54) rendered at 110 dpi are pixel-identical: 0 differing pixels of 1,171,170 on each page.

Why nothing changes. WeasyPrint runs no JavaScript, so DataTables never ran in a simplepdf build and the new script does not either — the PDF gets the server-rendered table in both cases. The things that reach WeasyPrint are:

  • the <table class="NEEDS_DATATABLES …"> markup — the hook class is unchanged, so simplepdf's _needs.scss rule (table.NEEDS_DATATABLES { table-layout: fixed; … }) keeps applying, which is why :colwidths: still lays out the second demo table;
  • the new needstable.css in place of datatables.min.css — every selector in it is scoped to elements the script creates (div.needstable, div.needstable-scroll, …), so without the script it is inert, exactly as the DataTables sheet was (its table.dataTable rules needed a class the script added);
  • a <script defer> tag WeasyPrint ignores, and the same sphinx_needs_collapse.js as before.

The only markup differences are additive attributes (scope, data-col, data-need-id, data-needstable-*), and :show_filters: now writing its paragraph after the table rather than inside it — the demo does not use that option, and in a PDF it renders as a paragraph after the table either way.

If simplepdf ever wants to adapt — it does not have to — the one honest improvement would be to key its needtable rule on table.NEEDS_TABLE, table.NEEDS_DATATABLES rather than the DataTables name alone, since both styles now share the same static shape; and a project that used simplepdf's ssp-landscape container for wide tables keeps working unchanged. The two things a theme might have styled that no longer exist are the .dataTables_* / .dt-button chrome classes and the --sn-color-datatable-* variables, both listed under "Removed" above; simplepdf styles neither.

@chrisjsewell
chrisjsewell merged commit c737c80 into master Sep 8, 2026
36 checks passed
@chrisjsewell
chrisjsewell deleted the feat/needstable-enhancer branch September 8, 2026 18:49
chrisjsewell added a commit that referenced this pull request Sep 8, 2026
`needstable.js` (#1920) is plain JavaScript typed through `// @ts-check`
and JSDoc, and `design/needstable-contract.md` §4 records the single
command that checks those types. It passes at the tip, but nothing ran
it, so any later edit could regress a type silently. This makes it a
gate.

- **A poe task, `typecheck-js-needs`**, holds the command with the
typescript version **pinned** (`typescript@5.9.3`): an unpinned `npx -p
typescript` resolves whatever compiler is newest that day, which would
let a TypeScript release turn a pull request red with nothing here
having changed. To bump it, edit that one literal and run the task.
5.9.3 is the newest 5.9.x; the file is also clean under 7.0.2, today's
`latest`.
- **One Lint step runs the task**, not a copy of the command, so the
gate a developer runs locally and the gate that blocks a pull request
cannot drift, the same arrangement as the prek hooks and `poe lint`.
Lint rather than the `tests-js` lane: it needs no browser, no sphinx and
no interpreter matrix, and Lint is a required status context in its own
right. No `setup-node`: `ubuntu-latest` ships node and npm, and `npx -y
-p` fetches the pinned compiler into the runner's npm cache for the run
(about 2.5 s cold on an empty cache, 23 MB unpacked).
- **Files checked**: `needstable.js` only. `sphinx_needs_collapse.js` is
jQuery-era, carries no `// @ts-check` and does not pass `--strict`;
#1922 ports it and is the pull request that adds the second line. The
task lists one path per line, so that is a one-line change.
- **Docs**: §4's "It is not a CI gate here" sentence now says the
opposite, and the root `AGENTS.md` commands block gains the task. No
changelog entry: nothing user-visible changed.

Proof the fence bites: with `this.sortColumn = -1` (declared `@type
{number}`) changed to a string, the task exits 2 with

```
src/sphinx_needs/libs/html/needstable.js(452,13): error TS2322: Type 'string' is not assignable to type 'number'.
```

Closes #1924
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement pkg: sphinx-needs The sphinx-needs distribution (packages/sphinx-needs): its code, tests and docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add sphinx-needs specific .css and .js only on html pages where necessary

2 participants