Skip to content

Add ui.skip_link for keyboard-accessibility skip links (WCAG 2.4.1) - #5790

Merged
falkoschindler merged 16 commits into
zauberzeug:mainfrom
evnchn:skip-to-main
Jun 8, 2026
Merged

Add ui.skip_link for keyboard-accessibility skip links (WCAG 2.4.1)#5790
falkoschindler merged 16 commits into
zauberzeug:mainfrom
evnchn:skip-to-main

Conversation

@evnchn

@evnchn evnchn commented Feb 16, 2026

Copy link
Copy Markdown
Collaborator

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:

  • is positioned offscreen by default (.nicegui-skip-link CSS) and only becomes visible when it receives keyboard focus,
  • is automatically moved to the top of the page layout so it is the first element keyboard focus reaches (multiple skip links keep creation order),
  • on activation, lets the browser handle fragment navigation natively, plus a small JS handler sets tabindex="-1" on the target and calls .focus() so non-focusable elements (e.g. a <div>) still receive focus.

API choices, with reasoning:

  • target is a required keyword-only argument — explicit over magic. The earlier draft inferred the target from client.next_element_id, which silently broke if any element was created in between.
  • Rendered as <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.
  • text parameter for the common case, context manager for advanced — pass text='Skip nav' for a custom label, or use with ui.skip_link(text='', target=...): to insert icons / custom child content.
  • Uses the existing getHtmlElement() helper from nicegui.js — consistent with how the rest of the codebase resolves NiceGUI element ids on the client.

Example:

@ui.page('/')
def page():
    ui.button('Navigation 1')
    ui.button('Navigation 2')
    with ui.column() as main:
        ui.label('Main content')
    ui.skip_link(target=main)  # auto-moved to top of layout

The website itself (main.py) uses the new element to skip past the header / nav menu into the sub-pages content.

Progress

  • The PR title is a short phrase starting with a verb.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added (focus behavior, custom text, context-manager content, document-order, multiple links in creation order all preceding regular content).
  • Documentation has been added (skip_link_documentation.py, registered under "Page Layout").
  • No breaking changes to the public API. (The earlier ui.skip_to_main from this PR was never released; the API was redesigned in response to review feedback before the first release.)

@evnchn evnchn added the feature Type/scope: New or intentionally changed behavior label Feb 16, 2026
@evnchn evnchn mentioned this pull request Feb 17, 2026
5 tasks
@falkoschindler

Copy link
Copy Markdown
Contributor

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.

@falkoschindler falkoschindler added the in progress Status: Someone is working on it label Feb 17, 2026
@falkoschindler falkoschindler added this to the 3.10 milestone Feb 17, 2026
@evnchn

evnchn commented Feb 17, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@evnchn evnchn added help wanted Status: Author is inactive, others are welcome to jump in and removed in progress Status: Someone is working on it labels Feb 17, 2026
evnchn and others added 2 commits March 29, 2026 18:45
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@evnchn evnchn added review Status: PR is open and needs review and removed help wanted Status: Author is inactive, others are welcome to jump in labels Mar 29, 2026
@evnchn

evnchn commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator Author

@falkoschindler Ready for your review into 3.10 I'd say. All checklist items done.

evnchn and others added 2 commits March 30, 2026 05:04
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 falkoschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_manager checks attributes but never clicks the button — it tests wiring, not behavior
  • get_attribute('innerText') is non-idiomatic for Selenium; .text is 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; a screen.should_contain or 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=
@evnchn evnchn changed the title ui.skip_to_main: skip to main content button Add ui.skip_link for keyboard-accessibility skip links (WCAG 2.4.1) May 14, 2026
@evnchn

evnchn commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

(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 dd396793. TL;DR: API is now ui.skip_link(text='...', *, target=element), rendered as <a href="#cN">, with the implicit-target magic removed entirely.

Point-by-point

1. 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). next_element_id mechanismtarget is now a required keyword-only argument. The client.next_element_id read is gone. Chicken-and-egg cases (link before target) are handled by holding a forward reference:

with ui.column() as main:
    ui.label('Main content')
ui.skip_link(target=main)  # auto-moved to top of layout

3. Name vs. capability mismatch — renamed to ui.skip_link (and CSS to .nicegui-skip-link). Tracks the WAI/WebAIM/GOV.UK term and removes the implicit "main" assumption.

4. JS click handler has no null guard — added if (el === null) return;.

5. Use getHtmlElement — done. Handler now reads the ID from e.currentTarget.hash (the link's own href fragment) and resolves via getHtmlElement(...). Bonus: e.currentTarget (the link) instead of e.target (whatever was actually clicked, which matters once you put an icon inside via a context manager).

6. Docstring — rewritten. Now covers WCAG, ordering semantics, <a> rationale, and the text='' + context manager pattern for custom content.

7. No text parameter — added text: str = 'Skip to main content' as the first positional. Context manager remains for icon / mixed content.

8. Padding — added padding: 0.5rem 1rem on :focus (kept off the hidden state so the offscreen box stays minimal).

Re. "should this inherit from ui.button / QBtn?" — went the other direction, actually: switched the markup from <button> to <a href="#cN">. That's the pattern WAI-ARIA APG, WebAIM and GOV.UK all use; screen readers announce "link" (which matches user expectation for a navigational element), the browser handles fragment navigation natively, and the JS-disabled fallback still does something useful. The handler still sets tabindex='-1' + .focus() so non-focusable targets (e.g. a <div>) actually receive focus.

9. __exit__ missing return annotation__exit__ removed entirely. It only existed to re-poke data-target = next_element_id after the with body, which the explicit-target API makes unnecessary.

10. Test quality / coverage — rewritten:

  • textContent instead of innerText / .text (works even when the element is positioned offscreen).
  • screen.wait_for(...) callable form instead of screen.wait(0.5).
  • New: test_link_precedes_other_content_in_document — uses compareDocumentPosition to verify the link appears before other content in document order (i.e. keyboard reaches it first).
  • New: test_multiple_skip_links_preserve_order_and_precede_content — covers your "scenario from the PR description with 3 skip buttons" + asserts both still precede regular content.
  • By.CSS_SELECTOR for consistency with the rest of the test suite.

Also went through one round of self-review with a separate agent before pushing, which is what caught the <button><a> decision. Happy to roll any of these back if you'd rather, especially the <a> change since it wasn't in your original list.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 SkipLink element and exports it as ui.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 assigned tabindex="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() calls move(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

Comment thread nicegui/elements/skip_link.py
Comment thread nicegui/static/nicegui.css
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.
@evnchn

evnchn commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

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.
@falkoschindler
falkoschindler self-requested a review May 18, 2026 12:39
@falkoschindler falkoschindler modified the milestones: Next, 3.13 May 18, 2026
falkoschindler and others added 8 commits June 8, 2026 10:56
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 falkoschindler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 that target should 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 from left: -9999px to the clip-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-matched white/black background) so it's self-contained and legible over any content.
  • Docs: moved skip_link next to the foundational page-structure elements (after fullscreen) rather than its incidental alphabetical slot between skeleton and splitter.
  • 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's is_displayed() ignores scroll position), and DOM-order assertions.

API and behavior are unchanged from your design. 👍

@falkoschindler
falkoschindler added this pull request to the merge queue Jun 8, 2026
Merged via the queue into zauberzeug:main with commit 078c41a Jun 8, 2026
7 checks passed
@evnchn

evnchn commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator Author

Posted by Claude Code on Evan's behalf.

Side tangent — ruled out: the new :focus background rule does not break the custom demo's bg-primary (verified, no action needed).

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 investigation

The "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 .classes('bg-primary text-white') to the skip link. A skip link is only ever visible while focused, so the focus rule and bg-primary are always in effect at the same time. If the focus rule won, the demo would render its primary background as plain white/black — and with text-white on top, that's white text on a white background in light mode (invisible).

Specificity says the focus rule should win:

  • .body--light .nicegui-skip-link:focus(0,3,0)
  • .bg-primary(0,1,0)

But it doesn't, because NiceGUI ships Quasar's color utilities with !important:

nicegui/static/quasar.important.prod.css:
  .bg-primary{background:var(--q-primary)!important}
  .text-white{color:#fff!important}

!important outranks the (non-important) focus rule regardless of selector specificity, so the demo keeps its primary background + white text as intended. The default skip link (no bg-* class) correctly falls through to the white/black focus background.

Conclusion: correct as-is. Worth being aware that any future skip-link :focus background added with !important would silently override a user's bg-* class — but nothing here does that.

falkoschindler added a commit to evnchn/nicegui that referenced this pull request Jun 9, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Type/scope: New or intentionally changed behavior review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants