Add ui.skip_link for keyboard-accessibility skip links (WCAG 2.4.1) - #5790
Conversation
|
Thanks for the contribution, @evnchn! The "skip to main content" feature is certainly a great accessibility improvement. One note for future PRs: It's best to lead with what the change does and how it works, so reviewers can quickly understand the PR. Personal context and background story are fine to include as a comment on the PR, but keeping the description itself focused on the technical substance helps people "pick up" the PR faster - and keeps commit messages clean when squash-merging. |
|
Been a while since I wrote any PR message. Will bear this in mind 🙇 @falkoschindler as you scheduled this for 3.10, do you think it's a good idea to schedule #5795 also for 3.10 as well? And preferably early 3.10 so that I can get the accessibility documentation by late 3.10. |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@falkoschindler Ready for your review into 3.10 I'd say. All checklist items done. |
Use JS click instead of native Selenium click for the skip-to-main button. The button is off-screen (left: -9999px) until focused, and Selenium native click() may check interactability before the browser re-layouts after focus(), causing ElementNotInteractableException on some Chrome versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
falkoschindler
left a comment
There was a problem hiding this comment.
Thanks again for the PR, @evnchn!
It needs some more work though. We should clarify API and behavior first before looking into some implementation details. Anyway, I'll move it to milestone "Next": We can do it whenever we have time, but don't need to specify an individual release.
1. PR description needs work
The PR description focuses on personal background and the process of building the feature rather than the technical substance. It should lead with what the feature does, why it's needed (accessibility / WCAG compliance), and how it works. The test script is helpful but belongs after a clear technical explanation.
2. Implicit targeting is surprising and fragile
The connection between ui.skip_to_main() and its target is invisible — the user just has to know that the next element they create becomes the target. There's nothing in the code that makes this relationship explicit. This is unlike any other NiceGUI element and will be a source of confusion. Consider accepting an explicit target parameter: ui.skip_to_main(target=main_content).
3. Name vs. capability mismatch
The name skip_to_main implies a single "main content" target, but the implementation supports multiple buttons targeting arbitrary elements. If the intent is to skip to different sections, the name is too narrow. If the intent is truly "skip to main content," why support multiple buttons?
The standard accessibility term for this pattern is "skip link" (see also WCAG 2.4.1, WebAIM). ui.skip_link would be both more standard and more flexible — it doesn't lock the API into the single-target assumption.
3. next_element_id is a fragile targeting mechanism
skip_to_main.py:32 and skip_to_main.py:36: The target is set to self.client.next_element_id, assuming the very next element created will be the intended target. This is the only place in the codebase that reads next_element_id outside of Element.__init__. If any internal NiceGUI bookkeeping creates an element in between (e.g. a future refactor adds a wrapper element, or a decorator/timer fires), the target silently points to the wrong element. The user has no way to detect or debug this.
4. JS click handler has no null guard — will throw on missing target
skip_to_main.py:27-30: If the target element is removed or never created (e.g. conditional rendering), document.getElementById(...) returns null and the next line throws TypeError: Cannot read properties of null.
5. Should use getHtmlElement instead of document.getElementById
skip_to_main.py:28: The JS handler manually constructs 'c' + e.target.dataset.target and uses document.getElementById. NiceGUI provides getHtmlElement(id) in nicegui.js which already handles the c prefix and is the idiomatic way to look up element DOM nodes. Other elements (e.g. code.py, scene_view.js) use getElement/getHtmlElement consistently.
6. Docstring doesn't explain usage
skip_to_main.py:8-17: The docstring describes what the button does but not how to use it. It doesn't explain the critical detail that the target is the next element created after the call, nor that the context manager resets the target on exit. A user reading only the docstring would have no idea how targeting works.
7. Constructor doesn't accept text parameter
skip_to_main.py:7: __init__ takes no arguments, so ui.skip_to_main('Go to content') doesn't work. Users must use the context manager just to change the label text. Since TextElement already supports a text parameter, consider exposing it. This would keep the simple case simple and reserve the context manager for truly custom content (icons, styling, etc.).
8. Default styling could use some padding
The focused button is visible but looks a bit cramped without any padding. Consider adding a small padding to the base .nicegui-skip-to-main class.
Also worth considering: should this inherit from ui.button (Quasar's QBtn) instead of a plain <button> via TextElement? That would give consistent styling with the rest of NiceGUI's components for free. On the other hand, a plain <button> is lighter and arguably more robust for an accessibility primitive. Either way, the current unstyled <button> sits in an awkward middle ground — not Quasar-styled, not explicitly minimal either.
9. __exit__ missing return type annotation
skip_to_main.py:34: def __exit__(self, *_) is missing the -> None return annotation. The *_ pattern is fine — it matches the convention in element.py, client.py, slot.py, etc.
10. Test quality and coverage
Existing tests:
test_context_managerchecks attributes but never clicks the button — it tests wiring, not behaviorget_attribute('innerText')is non-idiomatic for Selenium;.textis the standard way to get visible text'Jump to content' in button.get_attribute('innerText')is a loose substring check — doesn't verify that the original "Skip to main content" text was actually cleared by__exit__screen.wait(0.5)is a hard-coded sleep; ascreen.should_containor explicit wait-for-condition would be more robust
Missing coverage:
- DOM ordering logic (lines 22-25): if
ui.skip_to_main()is called after other elements, does it actually appear first? - Multiple
skip_to_main()on the same page (the scenario from the PR description with 3 skip buttons) - No subsequent element (edge case — target points to a non-existent ID)
Note: some of these gaps may be hard to test reliably (e.g. focus behavior in headless Selenium) or may become obsolete if the API changes based on earlier review findings (e.g. explicit target parameter would make the ordering and "no subsequent element" scenarios straightforward to test — or irrelevant).
- rename ui.skip_to_main → ui.skip_link (standard accessibility term; old name implied a single "main" target while the implementation supported arbitrary targets) - target is now an explicit required keyword argument; drop the implicit "next element" magic that read client.next_element_id - render as <a href="#cN"> instead of <button>: browser handles fragment navigation natively, a11y tree reports "link" (matches the WAI-ARIA Authoring Practices skip-link pattern); JS handler still sets tabindex='-1' + focus() so non-focusable targets work - add text parameter for the simple case; keep context manager for custom child content (icons, etc.) - JS handler guards against missing target, derives the ID from the link's hash via the shared getHtmlElement() helper, and uses e.currentTarget so clicks on child icons still resolve the link - CSS class renamed to .nicegui-skip-link with small focus padding - tests rewritten: cover focus behavior, custom text, context-manager custom content, document-order (link comes before navigation), and multiple skip links in creation order all preceding content - update website main.py demo to use explicit target=
ui.skip_to_main: skip to main content buttonui.skip_link for keyboard-accessibility skip links (WCAG 2.4.1)
|
(This response was drafted by Claude Opus 4.7 on Evan's behalf — code, tests and reasoning have been reviewed by Evan before posting.) Thanks for the detailed review @falkoschindler — addressed all 10 points in Point-by-point1. PR description — rewritten to lead with motivation, WCAG reference, and how it works. Test script kept but no longer at the top. 2. Implicit targeting is fragile + 3 (dup). with ui.column() as main:
ui.label('Main content')
ui.skip_link(target=main) # auto-moved to top of layout3. Name vs. capability mismatch — renamed to 4. JS click handler has no null guard — added 5. Use 6. Docstring — rewritten. Now covers WCAG, ordering semantics, 7. No 8. Padding — added Re. "should this inherit from 9. 10. Test quality / coverage — rewritten:
Also went through one round of self-review with a separate agent before pushing, which is what caught the |
There was a problem hiding this comment.
Pull request overview
Adds a first-class ui.skip_link element to NiceGUI to support keyboard-accessibility skip links for bypassing repeated navigation.
Changes:
- Introduces the
SkipLinkelement and exports it asui.skip_link. - Adds default CSS, Selenium coverage, and documentation demos/reference.
- Uses the new skip link on the NiceGUI website to jump to the main sub-page content.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
nicegui/elements/skip_link.py |
Implements the new skip link element, placement, href, and focus behavior. |
nicegui/static/nicegui.css |
Adds default hidden/focused skip-link styling. |
nicegui/ui.py |
Exposes ui.skip_link in the public API. |
tests/test_skip_link.py |
Adds browser tests for text, focus, ordering, and multiple links. |
website/documentation/content/skip_link_documentation.py |
Adds skip-link documentation demos and API reference. |
website/documentation/content/section_page_layout.py |
Registers the skip-link docs under Page Layout. |
main.py |
Adds a skip link to the documentation website layout. |
Comments suppressed due to low confidence (2)
nicegui/elements/skip_link.py:48
- The click handler unconditionally sets
tabindex="-1"on the target. If the target is already focusable (for example a button/input) or the user assignedtabindex="0", activating the skip link removes that element from the normal tab order for the rest of the session, which is an accessibility regression. This should preserve an existing/natural tab stop and only add temporary focusability when needed.
el.setAttribute('tabindex', '-1');
nicegui/elements/skip_link.py:43
- This ordering is only enforced at construction time. Other layout elements can later move themselves ahead of the skip link (for example
ui.header()callsmove(target_index=0)), so a skip link created before a header/drawer may no longer be the first focusable element and will not reliably bypass repeated navigation. The layout ordering needs to remain stable even when later layout elements are added.
for index, child in enumerate(self.parent_slot.children):
if not isinstance(child, SkipLink):
self.move(target_index=index)
break
Per Copilot review feedback on PR zauberzeug#5790: because the link is created under context.client.layout (so it can position itself at the top), it isn't a descendant of the calling context — a sub_pages route change or refreshable rebuild would leave stale links pointing at deleted targets. Add a hidden canary element in the calling context whose finalizer deletes the link, matching the pattern used by ui.dialog. New test test_link_is_deleted_with_calling_context covers a clear() of the parent container.
|
Just need to resolve merge conflict |
Conflicts: - nicegui/ui.py: main refactored to lazy imports via _LAZY_IMPORTS dict + TYPE_CHECKING. Adopted main's structure; registered skip_link in _LAZY_IMPORTS and TYPE_CHECKING block. - website/documentation/content/section_page_layout.py: both branches added sibling imports; kept skip_link_documentation alongside main's sortable_documentation. Ruff re-sorted the import block.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
falkoschindler
left a comment
There was a problem hiding this comment.
Looks great — approving. Thanks for the clean, well-reasoned implementation and the thorough test coverage.
I pushed a few refinements on top while reviewing:
- Docstring: added the
*Added in version 3.13.0*marker and a caveat thattargetshould be a stable container (the link resolves it by its creation-time id, so transient content recreated by a refreshable / sub-page route change would silently no-op). - Hidden state (
.nicegui-skip-link): switched fromleft: -9999pxto theclip-path: inset(50%)+ 1px idiom (matching Tailwind/GOV.UK), so it's direction-agnostic and correct under RTL. - Revealed state (
:focus): pinned with logical properties (inset-block-start/inset-inline-start) so it lands in the top-start corner in both LTR and RTL, and gave it the connection/error-popup look (grey border, rounded corners, soft shadow, theme-matchedwhite/blackbackground) so it's self-contained and legible over any content. - Docs: moved
skip_linknext to the foundational page-structure elements (afterfullscreen) rather than its incidental alphabetical slot betweenskeletonandsplitter. - Tests: reworked into genuine behavioral checks — Tab to reach the link (proving keyboard order) and Enter to scroll off-screen content into the viewport (verified via
getBoundingClientRect, since Selenium'sis_displayed()ignores scroll position), and DOM-order assertions.
API and behavior are unchanged from your design. 👍
|
Posted by Claude Code on Evan's behalf. Side tangent — ruled out: the new While re-reviewing the recent refinements, the popup-style focus styling caught my eye as a possible conflict, so I chased it down. Posting the negative result since "ruling it out" is itself worth recording. The investigationThe "Style revealed skip link like the connection popup" commit added: .body--light .nicegui-skip-link:focus { background-color: white; }
.body--dark .nicegui-skip-link:focus { background-color: black; }The custom-content demo applies Specificity says the focus rule should win:
But it doesn't, because NiceGUI ships Quasar's color utilities with
Conclusion: correct as-is. Worth being aware that any future skip-link |
Capture two public API additions that landed in the 3.13 milestone: - ui.skip_link (zauberzeug#5790): new keyboard-accessibility element — code example in the page-structure block plus a Layout & Structure reference row. - ui.plotly.run_plot_method (zauberzeug#6060): new helper for driving plotly.js directly — added to the Plotly snippet, mirroring the existing AG Grid run_grid_method example. Not the N-1 finalization pass; just closes today's gaps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Motivation
Keyboard-only users (no pointer, screen reader, switch device, broken touchpad, …) need a way to bypass repetitive navigation and jump to the page's main content. This pattern is called a skip link and is the standard solution for WCAG 2.4.1 "Bypass Blocks". Until this PR, NiceGUI offered no built-in primitive for it.
Reference implementations and guidance: WebAIM, W3C WAI, GOV.UK Design System.
Implementation
ui.skip_link(text='Skip to main content', *, target)adds an<a href="#c{target.id}">element that:.nicegui-skip-linkCSS) and only becomes visible when it receives keyboard focus,tabindex="-1"on the target and calls.focus()so non-focusable elements (e.g. a<div>) still receive focus.API choices, with reasoning:
targetis a required keyword-only argument — explicit over magic. The earlier draft inferred the target fromclient.next_element_id, which silently broke if any element was created in between.<a>, not<button>— matches the WAI-ARIA Authoring Practices skip-link pattern; screen readers announce it as a "link", and the native fragment-navigation behavior keeps a working fallback when JS is disabled.textparameter for the common case, context manager for advanced — passtext='Skip nav'for a custom label, or usewith ui.skip_link(text='', target=...):to insert icons / custom child content.getHtmlElement()helper fromnicegui.js— consistent with how the rest of the codebase resolves NiceGUI element ids on the client.Example:
The website itself (
main.py) uses the new element to skip past the header / nav menu into the sub-pages content.Progress
skip_link_documentation.py, registered under "Page Layout").ui.skip_to_mainfrom this PR was never released; the API was redesigned in response to review feedback before the first release.)