Programa: vista accesible + mejoras de a11y del widget de Pretalx#215
Conversation
- Add global skip link to main content (3 i18n locales) - Wrap schedule in region with aria-labelledby, add live region announcing session count changes after filtering - Reinforce dialog ARIA (aria-modal, role, aria-labelledby) via Shadow DOM CSS/attribute injection - Add labels to search input and timezone select inside the widget - Restore focus to filter trigger when dialog closes - Set body[modality] attribute so widget's :focus-visible rings appear for keyboard users - Override --pretalx-clr-success to a distinguishable green Fixes 3 issues from initial a11y pass: - Widget uses Shadow DOM (Vue 3 attachShadow), not light DOM — all queries now use shadow root - modality must be a direct attribute, not data-modality - success color override must live on host's style attribute to cross the shadow boundary
There was a problem hiding this comment.
Debo advertir que el control UI de horarios no es accesible a lectores de pantalla. No es que tenga algún pequeño problema, es que directamente no es navegable.
Apruebo para tener la versión visual en producción y, si os parece, estudiamos si podemos implementar un control complementario y compatible con el lector de pantalla.
Mirar #216
…dget Addresses #216 — the Pretalx widget's CSS grid layout is fundamentally hard to navigate with a screen reader, no matter how much ARIA we inject. Adds a parallel accessible list view rendered server-side from the Pretalx API, with the widget preserved as an opt-in visual alternative. Data layer: - src/lib/schedule.ts — fetches schedule.json at build time, normalises per-day structure, returns null on failure (graceful fallback) - src/types/schedule.ts — TS types for the Pretalx export shape UI: - src/components/ScheduleList.astro — semantic <ol> per day with <article> per session, <time datetime>, <details> for abstracts, lang attribute on text in non-default languages - Toggle in ProgramaPage.astro: <fieldset> + radio group, default to the accessible list, persists choice in localStorage - Widget remains as opt-in 'Visual grid' view, with all a11y enhancements from the previous commit intact i18n: - Adds 15 new strings to programa/{es,en,ca} (view toggle, day label, speaker/room/track/abstract labels, empty state) Edge cases: - Pretalx down or schedule not published: render friendly empty state with link to pretalx.com - Build-time fetch is silent on failure (warning to stderr, no crash)
Update: address #216Hecho un commit extra ( Qué cambióNueva vista: lista accesible (default ahora)
Widget sigue como vista opt-in ("Grilla visual")
Resiliencia
Archivos nuevos
Testing manual pendiente
Probá y decime si el compi queda conforme con esto o si hay que iterar. |
Visual users land on the Pretalx widget by default; the accessible list is now a one-click toggle away for screen reader users and anyone who prefers a semantic structure.
There was a problem hiding this comment.
El commit a7bcdb7 soluciona el #216, fantástico.
Algunas cositas que repensaría:
time
El atributo time nos resta información sobre la hora fin:
<time datetime="2026-11-06T16:00:00+01:00">
16:00–17:30
</time>Cambiar por algo parecido a:
<p>
<span class="sr-only">De </span>
<time datetime="2026-11-06T16:00:00+01:00">16:00</time>
<span aria-hidden="true">–</span>
<span class="sr-only"> a </span>
<time datetime="2026-11-06T17:30:00+01:00">17:30</time>
</p>Duración
La duración se indica dos veces:
<span class="text-xs text-pycon-gray-25/70 shrink-0">01:30</span>
<-!...->
<span class="text-xs text-pycon-gray-25/70 shrink-0">Taller (90min)</span>Supongo que Taller (90min) no se debe tocar pues debe ser el tipo de actividad. El 1:30 puede dar pie a confusión (que alguien piense que es a la una y media), añadir duración al menos para SR:
<span class="text-xs text-pycon-gray-25/70">
<span class="sr-only">Duración: </span>
01:30
</span>Otros
En las descripciones no se han respetado los saltos de línea, el json nos retorna:
... en Venecia.\n\nLos aeropuertos ...
Pero esos saltos no se trasladan a html, nos falta algo como <p class="whitespace-pre-line">
# Conflicts: # src/components/ProgramaPage.astro # src/pages/[lang]/programa.astro
When a talk is in English (or any non-page language), screen readers
were pronouncing the original-language content with the page's voice
because the surrounding elements inherited the document lang.
Add lang={talkLang(s)} on:
- <h3> (talk title)
- <p> subtitle (when present)
- <p> abstract
Helper talkLang() maps s.language to a valid BCP-47 value and falls
back to the page lang if Pretalx returns an unknown code.
The Pretalx widget already persists favourites in localStorage under 'pycones-2026_favs' as a JSON array of session codes. This commit mirrors that behaviour on the accessible list so users can bookmark sessions in either view and have the preference stick. UI: - Heart icon button on every session card, pushed to the right of the metadata row - aria-pressed for state, aria-label that flips between 'Add to favourites' / 'Remove from favourites' based on state - Visual state: outline heart (gray) when off, filled (orange) when on - sr-only label 'Favourite' for screen readers Script: - Reads/writes the same 'pycones-2026_favs' key the widget uses - Click handler: toggle, persist, update DOM - 'storage' event listener: re-sync on cross-tab changes - 'pycones:view-changed' custom event: re-sync when user switches to the list view (in case the widget mutated localStorage in the same session) i18n: - favorite, favoriteAdd, favoriteRemove (es/en/ca)
Two improvements to the favorite button accessibility:
1. Static label, state via aria-pressed only.
The W3C ARIA Authoring Practices Guide recommends that toggle
button labels stay stable across states. Screen reader users
had been hearing a different label each time the button changed
('Add to favourites' → 'Remove from favourites'), which is more
confusing than just 'Favourite, button, pressed/not pressed'.
2. Live region announces the result of the click.
Previously the user clicked and got no audible confirmation. Now
a polite live region says 'Added to favourites' / 'Removed from
favourites' after each toggle. The text is cleared and re-set
with a forced reflow so identical back-to-back messages still
re-announce.
i18n: replaced favoriteAdd/favoriteRemove with favoriteAdded/
favoriteRemoved (past tense, used as announcement text).
…lose The favorite button was being pushed to the far right of the full-width viewport via ml-auto, which on desktop made it feel disconnected from the rest of the session metadata. Add max-w-3xl mx-auto to the schedule-list container so the day headings and session cards stay within a comfortable reading width (768px), keeping the heart visually close to the time/track info on the same row.
A toggle button at the top of the list view that filters the schedule to show only the user's bookmarked sessions. - aria-pressed toggle button with heart icon - Live count of favourites in the label - CSS hides non-favourite <li> elements when the filter is active (via the is-favorite class added by syncFavorites on the <li>) - Empty state: if the filter is on and there are 0 favourites, show a friendly message instead of a blank page - i18n: favoritesOnly, noFavorites (es/en/ca) Accessibility: - The button itself is a proper toggle (aria-pressed, not a checkbox) - The filter state is purely visual + local, no new localStorage key (the favourite list itself is already persisted)
…status The 'Only favourites' filter button wasn't doing anything because two click handlers were firing on each click: 1. The new event-delegation handler that toggles aria-pressed and the CSS class 2. The old direct listener (from the previous commit) that toggled aria-pressed again Net effect: aria-pressed went true → false → true, leaving the button exactly as it was. No visible change, no class added. Fix: remove the old direct listener; the event-delegation handler in the document click listener now owns both the per-session favourite toggle and the filter toggle. Also refactored to share a single announce() helper. While here: - Replace the buggy 'has-[:checked]:' Tailwind utilities (the button has no :checked state, it uses aria-pressed) with the correct 'aria-pressed:' variants — those have proper specificity and the orange-pressed look now actually applies - Add status announcements for the filter toggle via the existing #favorite-status live region: 'Favourites filter on. Showing N favourite session(s).' / 'Favourites filter off. Showing all sessions.' - Decompose the i18n message into prefix + singular + plural words (filterOnPrefix, filterOnSingular, filterOnPlural, filterOff) so the script can build the count-aware message at runtime
Switch from event delegation to a direct click handler bound to the button via getElementById. Adds try/catch + console.log statements so we can see in the browser console whether the script is running, the button is being found, and clicks are reaching the handler. The button now has id='favorites-filter-btn' (was data-filter-favorites). The script tries to bind the handler: - immediately (in case the DOM is ready) - on DOMContentLoaded (in case the script runs before DOM) - on astro:page-load (in case the page is a view transition) - guarded with dataset.bound so it only runs once If the filter still doesn't work, the browser console will show which of these steps is failing.
The previous attempt to fix the initial-load 'jump' by ignoring stale localStorage values was too restrictive. The team wants the user's choice to actually persist across visits. New behavior: - First visit (no saved value): default to visual grid - After user picks list: save to localStorage, restore list on next visit - After user picks grid: save to localStorage, restore grid on next visit Legacy users who had 'list' saved from the old default will see a one-time jump from grid → list on their first visit after this change. After that they can click the grid radio to overwrite the saved value and stop the jump. Also cleaned up: - Removed debug console.log statements left over from the filter fix - Fixed a duplicated function body in the inline script that was breaking astro check
…scope The 'only favourites' filter was toggling the button state and adding the .filter-favorites-only class to the list view, but the session cards were not being hidden. Reason: the CSS rule was in ProgramaPage.astro and Astro's scoped styles injected data-astro-cid-nm3rvpko into every selector, including .schedule-list and li. But those elements are rendered by ScheduleList.astro which has its own data-astro-cid-zmszfzov, so the selector never matched. Fix: extract the filter CSS into an is:global block so the selector reaches across the component boundary. Verified end-to-end with Playwright: - 0 favourites: 56/56 hidden (everything is filtered out) - 1 favourite set: 1 visible, 55 hidden - Toggle off: 56 visible again
Pre-paint script in <head> now sets data-schedule-view on <html> before the first paint, so the saved view (list/grid) is applied without a flash of the other view. When the URL has a session hash, the view is forced to 'list' so the user lands directly on the targeted session. CSS now drives both view visibility and the visual highlight on the view-mode toggle labels from the same data-schedule-view attribute, so the active state matches the view on first paint. The radio's hardcoded checked attribute is removed to avoid a visual mismatch where grid would flash as active before the JS sync ran. The favorites filter CSS was moved to Layout.astro's global styles because the target <li> elements live in a child component (ScheduleList) with a different Astro scope, which made the previously-scoped rule unable to match.
francescarpi
left a comment
There was a problem hiding this comment.
Funciona perfecto!
He probado el desplegado y funciona perfecto! |
Resumen
Closes #216 con un approach de dos puntas:
bb27cbc): parches de a11y sobre el widget embebido (skip link, ARIA en dialog, focus restoration, modality script, etc.)39584b0): vista de lista semántica renderizada server-side desde la API de Pretalx. Es la solución de fondo — un grid CSS complejo no se puede hacer accesible desde afuera con parches ARIA.El widget sigue existiendo como vista opt-in para usuarios visuales, pero la lista accesible es el default.
Cambios principales
Vista accesible (default)
<ol>por día con<article>por sesión<time datetime>,<h3>,<dl>para metadata<details>para abstracts colapsableslangen abstracts según idioma del talk<fieldset>+ radios para el toggle, persiste enlocalStorageWidget de Pretalx (vista opt-in)
body[modality=keyboard](atributo directo)--pretalx-clr-successa#2e7d32(distinguible para deuteranopia)Data layer
src/lib/schedule.ts— fetch del schedule en build time, con fallback amigablesrc/types/schedule.ts— tipos del export de Pretalxschedule/export/schedule.json(todo viene resuelto: room names, tracks, types en string, ya agrupado por día)i18n
src/i18n/menu/{es,en,ca}.ts—skipToContentsrc/i18n/programa/{es,en,ca}.ts—a11y(status, labels) +list(toggle, day, speakers, room, abstract, empty state)Archivos
src/types/schedule.tssrc/lib/schedule.tssrc/components/ScheduleList.astrosrc/components/ProgramaPage.astrosrc/pages/[lang]/programa.astrosrc/layouts/Layout.astro.skip-link+.sr-onlysrc/i18n/menu/{es,en,ca}.tssrc/i18n/programa/{es,en,ca}.tsBugs encontrados durante implementación
El widget de Pretalx es Vue 3 compilado con
attachShadow({mode:'open'}), no renderiza en light DOM como se asumió inicialmente. Implicó 3 correcciones:pretalx-schedule.querySelector('#filter-bottom-sheet-dialog')no encuentra el dialog porque está dentro del shadow root. Helper$inWidget()prueba light DOM y luegoshadowRoot.querySelector.body[modality=keyboard]es atributo directo, nodata-modality.--pretalx-clr-successva en el atributostyledel host.Testing
pnpm format→ OKpnpm exec astro check→ 0 errors, 0 warnings/es/programa→ ¿la lista suena natural al navegar con flechas?/ca/programa→ ¿pronunciación correcta del catalán?schedule.ts) → ¿se ve el fallback?Lo que dejé fuera (a propósito)
start + room.language).