Canon nuevo, eidos-native (1 parte, 0 eventos): la pieza de WCAG 2.4.1 que `site-header` difirió a los shells hace tres semanas y que `app-shell` (F3.1) necesita para existir. Nace de su fase 0, decisión Q4 firmada. Lo que decide, y por qué: - **Las palabras son suyas.** «Ir al contenido principal» no es copy: es el nombre de un contrato. La regla del tier (B-7 / D-BLK.5) apunta a esta puerta — si un string parece inevitable, lo posee el componente canónico vía `texts:` + langs. Por eso el prop es `region`, no `label`, y el vocabulario es la lista de landmarks de la APG. Cada clave es una FRASE ENTERA: en castellano el artículo se contrae con la región, así que un «Ir a» + sustantivo saldría mal en media catálogo. - **`region` no se estampa**: elige un texto y ninguna capa lo lee. Declararlo repetiría el error que `Affix` corrigió el 2026-08-15. - **No compone `Link`**: `Link` es tinta en línea y todo su API describe texto dentro de un párrafo. Esto es cromo que aparece de la nada, con su geometría y su suelo; envolverlo sería pisar todos sus props y aun así dejarlos en la API pública. Los componentes del canon renderizan sus nativos; son los blocks los que no pueden. - **El handler mueve el FOCO**, que es lo que el fragmento no hace: un `href` desplaza la vista y deja el foco en el enlace, y el siguiente Tab vuelve al cromo que el lector pidió saltar. El destino se hace enfocable sólo mientras dura el salto. El `href` se queda: es el camino sin JS. - **Peldaño propio de z (950), el más alto de la escalera estática.** No puede compartir el de `affix`: un empate lo rompe el orden del DOM y este enlace es por definición el primer elemento del documento, así que perdería contra cualquier aviso fijado debajo — el fallo medido en A-95. Medido con Playwright (el pane del navegador no tiene el foco del SO, así que `:focus` nunca casa ahí): en reposo 1x1 con clip-path; enfocado `position: fixed`, z 950, píldora de 181x36 sobre `primary-solid` con tinta blanca y anillo de foco, igual en claro y en oscuro; el salto deja el foco en `#demo-main` y el `tabindex` temporal se limpia en el blur; en RTL la píldora espeja al otro borde (x 338 -> 761) sin una segunda regla. Y la demo se corrigió a sí misma: decía «Tab desde el botón de abajo» y la medición enseñó que desde ahí el foco va hacia delante. El botón sube encima del marco, y de paso salió la otra mitad de la lección — por tabulación NUNCA se aterriza en el `main`, porque no tiene nada enfocable. Justo por eso el destino necesita el `tabindex` prestado. Guards: `component:audit --only skip-link` PASS · `morfo:check` PASS · `eidos-lint` 2 selectores morfo-backed, 0 inválidos · `docs:check` 0/0 · `svelte-check` 72, ninguno aquí. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>alpha-0.1-background
parent
f7be59cdcf
commit
9fd54c80fa
@ -0,0 +1,123 @@
|
||||
# SkipLink
|
||||
|
||||
The bypass affordance of **WCAG 2.4.1 Bypass Blocks (level A)**: a link that is
|
||||
invisible until it takes focus, and that jumps sequential navigation past the
|
||||
chrome every page repeats. It is meant to be the first focusable element on the
|
||||
page, so it is the first thing a keyboard user meets.
|
||||
|
||||
```svelte
|
||||
<SkipLink to="app-main" />
|
||||
<SkipLink to="app-nav" region="navigation" />
|
||||
<SkipLink to="app-filters" region="complementary">Skip to filters</SkipLink>
|
||||
```
|
||||
|
||||
Built 2026-08-18 as F1 of the `app-shell` phase 0 (`PLAN-blocks.md` §F3.1,
|
||||
decision Q4). The tier had already ruled in F2.1 that «the shells own the
|
||||
skip-link» — and then there was nothing for a shell to own it WITH.
|
||||
|
||||
## Baseline
|
||||
|
||||
- **Classification**: eidos-native (`scope: ['eidos']`), **passive** — 0 sema
|
||||
events, 0 keyboard handlers of its own. `apg: none`: it is a link, and no
|
||||
WAI-ARIA widget pattern applies.
|
||||
- **Anatomy**: one part, the anchor (`data-skip-link`).
|
||||
- **Technique**: WCAG **G124** — «adding links at the top of the page to each
|
||||
area of the content» — rather than **G1** («a link… that goes directly to the
|
||||
main content area»). One `SkipLink` per region is G124 by composition; a
|
||||
single one to `main` is G1. Both are sufficient techniques; which you get is
|
||||
the consumer's call, not a variant of this component.
|
||||
|
||||
## Comparativa
|
||||
|
||||
| Ref | Qué trae | Qué adoptamos / qué no |
|
||||
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Atlassian** `navigation-system` (y el `page-layout` que sustituye) | La referencia más rica: genera un MENÚ de skip-links a partir de los slots montados; cada slot lleva `id` + `skipLinkLabel`, el menú aparece con el foco, Escape lo cierra y mueve el foco detrás; `useSkipLink(id, label)` para registrar los propios | Adoptamos que los enlaces salgan de las REGIONES montadas, no de una lista escrita a mano. No adoptamos el menú: es un widget con foco, orden y descarte propios — otro componente, y sin consumidor todavía (registrado en Gaps) |
|
||||
| **Shopify Polaris** `Frame` | `skipToContentTarget` (una ref); si falta, apunta a `AppFrameMain`; el handler hace `preventDefault` y enfoca a mano | Adoptamos el handler: mover el foco a mano es lo único que funciona en todos los motores. No adoptamos la ref — pedimos un `id`, que es lo que el landmark ya tiene |
|
||||
| **Mantine** `AppShell` · **shadcn** `Sidebar` · **AntD** `Layout` · **Toolpad** `DashboardLayout` | Nada: ninguno trae skip-link | — |
|
||||
| **GOV.UK Design System** | El patrón de referencia del oficio: sr-only hasta el foco, píldora arriba a la izquierda, `href` real | Adoptamos los dos: la técnica sr-only (no `top: -100px`) y conservar el `href` como camino sin JS |
|
||||
|
||||
⚠️ El dossier del tier afirmaba que **ninguna** referencia traía skip-link y que
|
||||
era superación nuestra. Es falso, y está corregido en
|
||||
`RESEARCH-blocks-references.md` (2026-08-18). Lo que sí es superación: que las
|
||||
palabras vengan del sistema de idiomas y que el enlace salga del mismo contrato
|
||||
que estampa el landmark — en las dos referencias que lo traen, el `id` y la
|
||||
etiqueta los escribe el consumidor.
|
||||
|
||||
## Decisiones
|
||||
|
||||
- **Las palabras son del componente, no del consumidor.** «Ir al contenido
|
||||
principal» no es copy: es el nombre de un contrato de accesibilidad. La regla
|
||||
del tier (B-7 / D-BLK.5) apunta a esta puerta exacta — si un string parece
|
||||
inevitable, es superficie de contrato y lo posee el componente canónico, vía
|
||||
`texts:` del morfo + langs. Por eso el prop es `region`, no `label`.
|
||||
- **El vocabulario es la lista de landmarks de la APG**, y cada clave es una
|
||||
FRASE ENTERA, nunca un «Ir a» + sustantivo: en castellano el artículo se
|
||||
contrae con la región («al contenido principal», «a la navegación»), así que
|
||||
una etiqueta concatenada saldría mal en media catálogo. Una región que la
|
||||
lista no sabe nombrar —dos paneles `complementary` que hay que distinguir—
|
||||
pasa sus palabras como children, y ahí sí las posee la app.
|
||||
- **`region` no se estampa en el DOM.** Elige un texto y nada más; ninguna capa
|
||||
selecciona por él. Declararlo repetiría el error que `Affix` shippeó un día y
|
||||
corrigió el 2026-08-15 (morfo es el contrato ENTRE capas).
|
||||
- **No compone `Link`, y es a propósito.** `Link` es tinta en línea: hereda la
|
||||
tipografía ambiente, pinta subrayado, y toda su superficie (`variant` /
|
||||
`underline` / `size` / `color`) describe texto dentro de un párrafo. Esto no
|
||||
es texto dentro de un párrafo: es cromo que aparece de la nada, con su
|
||||
geometría y su suelo. Componerlo sería un envoltorio que pisa todos los props
|
||||
del envuelto y aun así los deja en el API pública, prometiendo un tratamiento
|
||||
en línea que este componente no puede cumplir. Los componentes del canon
|
||||
renderizan sus propios nativos (`Link` renderiza `<a>`, `Sidebar.MenuButton`
|
||||
también); son los BLOCKS los que no pueden (B-2).
|
||||
- **`<a>` con `href`, no `<button>`.** El destino es un sitio del documento, así
|
||||
que la navegación por fragmento del navegador se queda de camino de reserva:
|
||||
sin JS, antes de hidratar, o si el handler falla, el enlace sigue llevando.
|
||||
- **El handler mueve el FOCO, que es lo que el fragmento no hace.** Un `href`
|
||||
desplaza la vista pero deja el foco en el enlace en cualquier motor que no
|
||||
encuentre enfocable el destino — y entonces el siguiente Tab vuelve al cromo
|
||||
que el lector acaba de pedir saltar. El destino se hace enfocable
|
||||
(`tabindex="-1"`) sólo mientras dura el salto y se limpia en el `blur`: dejarlo
|
||||
puesto sería inocuo para el orden de tabulación pero deshonesto en el árbol de
|
||||
accesibilidad, un `<main>` marcado como enfocable para siempre.
|
||||
- **Oculto por la técnica sr-only, nunca por `display: none`, `visibility:
|
||||
hidden` ni `tabindex` negativo**: las tres lo sacan del orden de tabulación,
|
||||
que es lo único para lo que existe. Tampoco por `inset-block-start: -100px`,
|
||||
el truco clásico: mover un elemento enfocable fuera de pantalla hace que
|
||||
algunos motores desplacen la página a perseguirlo.
|
||||
- **`:focus`, no sólo `:focus-visible`.** Se alcanza con Tab, así que
|
||||
`:focus-visible` bastaría para el caso normal; `:focus` cubre además el
|
||||
programático (una app que le manda el foco al cambiar de ruta), que es lo que
|
||||
hacen Polaris y Atlassian.
|
||||
- **Peldaño propio en la escalera de z (`--z-index-skip-link: 950`), y es el más
|
||||
alto de la estática.** No puede compartir el de `affix`: un empate lo rompe el
|
||||
orden del DOM, y este enlace es por definición el PRIMER elemento del
|
||||
documento, así que perdería contra cualquier aviso fijado debajo — el fallo
|
||||
medido en A-95, 49px de cabecera tapados por una tira. Mientras hay un modal o
|
||||
un menú abiertos los dos nunca compiten: el foco está atrapado dentro y este
|
||||
enlace no puede recibirlo.
|
||||
- **El anillo de foco se queda** aunque la píldora sólo exista estando enfocada:
|
||||
sobre una superficie parecida al color de la píldora, aparecer no es un
|
||||
indicador de foco visible (WCAG 2.4.11). Y es un valor de configuración
|
||||
(`--focus-ring-*`), nunca un color por componente.
|
||||
|
||||
## Passive justification
|
||||
|
||||
SkipLink declara **0 eventos de sema**. Lo que hace —mover el foco a una región
|
||||
del documento— es navegación, y la navegación no suena en este sistema: la misma
|
||||
razón por la que una fila del `Sidebar` que navega es muda («navigating a row is
|
||||
native and does not sound», README de `sidebar`), y por la que `Link` no emite.
|
||||
El acto perceptible pertenece a lo que el lector hace DESPUÉS de llegar, no al
|
||||
salto.
|
||||
|
||||
Tampoco tiene capa soma: no hay estado que sostener ni nada que observar. El
|
||||
contraste que hace la decisión no trivial es `Sticky`, que también declara 0
|
||||
eventos y sí gana soma — porque DETECTA (sentinel + IntersectionObserver) y
|
||||
publica `data-stuck`. Aquí no hay nada que detectar: el handler corre, mueve el
|
||||
foco y termina.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposición |
|
||||
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Menú de skip-links** (Atlassian: primer foco, lista de regiones, Escape cierra y mueve el foco detrás) | **diferir** — es un widget con foco, orden y descarte propios, o sea otro componente canónico, no un `variant` de éste. Su disparador natural es una página con ≥4 regiones saltables; hoy el `app-shell` tiene tres y los enlaces sueltos las cubren |
|
||||
| Registro automático desde el landmark (que la región publique su `id` y su nombre, y el enlace salga solo) | **diferir** — lo hace `app-shell` para SUS regiones, que es donde el conocimiento vive. Un registro global pediría un servicio, y un componente pasivo no lo abre |
|
||||
| `scroll-margin-block-start` sobre el destino (para que no quede bajo una cabecera pegada) | **del consumidor** — el que sabe la altura de la cabecera es el shell, y sólo bajo `scroll="body"`. Anotado en su ficha |
|
||||
@ -0,0 +1,11 @@
|
||||
// SkipLink — the bypass affordance of WCAG 2.4.1 (level A).
|
||||
//
|
||||
// import { SkipLink } from '$uix/eidos/components/skip-link';
|
||||
//
|
||||
// <SkipLink to="app-main" />
|
||||
// <SkipLink to="app-nav" region="navigation" />
|
||||
import SkipLink from './skip-link.svelte';
|
||||
|
||||
export { SkipLink };
|
||||
export default SkipLink;
|
||||
export type { SkipLinkProps, SkipLinkRegion } from './types';
|
||||
@ -0,0 +1,69 @@
|
||||
/*
|
||||
* SkipLink recipe — the bypass affordance of WCAG 2.4.1.
|
||||
*
|
||||
* Two states and nothing in between: out of sight while it does not have
|
||||
* focus, a pill against the start corner of the viewport while it does.
|
||||
*
|
||||
* The hidden state is the canonical sr-only technique and NOT
|
||||
* `display: none` / `visibility: hidden` / a negative `tabindex`: all three
|
||||
* take the link out of the tab order, which is the one thing it exists to be
|
||||
* in. It also is not `inset-block-start: -100px` — the classic trick — because
|
||||
* moving a focusable element off-screen makes some engines scroll the page to
|
||||
* chase it before the focus style lands.
|
||||
*
|
||||
* Public tokens: `--skip-link-z-index`, `--skip-link-offset`,
|
||||
* `--skip-link-padding-block`, `--skip-link-padding-inline`,
|
||||
* `--skip-link-radius`, `--skip-link-bg`, `--skip-link-fg`,
|
||||
* `--skip-link-shadow`.
|
||||
*/
|
||||
|
||||
[data-skip-link] {
|
||||
/* sr-only: in the accessibility tree and in the tab order, out of the
|
||||
picture. `position: absolute` (not fixed) here so a zero-size box never
|
||||
participates in the viewport-fixed stacking context it does not need. */
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
clip-path: inset(50%);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Focused: a real pill, fixed to the START corner — logical insets, so RTL
|
||||
* mirrors it with no second rule and no `[dir]` selector.
|
||||
*
|
||||
* `:focus-visible` alone would be wrong: a skip link is reached by Tab, and
|
||||
* the whole point is that it appears then. `:focus` covers the programmatic
|
||||
* case too (an app moving focus to it after a route change), which is the
|
||||
* behaviour Polaris and Atlassian both ship.
|
||||
*/
|
||||
[data-skip-link]:focus {
|
||||
position: fixed;
|
||||
inset-block-start: var(--skip-link-offset);
|
||||
inset-inline-start: var(--skip-link-offset);
|
||||
z-index: var(--skip-link-z-index);
|
||||
inline-size: auto;
|
||||
block-size: auto;
|
||||
margin: 0;
|
||||
padding: var(--skip-link-padding-block) var(--skip-link-padding-inline);
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
clip-path: none;
|
||||
border-radius: var(--skip-link-radius);
|
||||
background: var(--skip-link-bg);
|
||||
color: var(--skip-link-fg);
|
||||
box-shadow: var(--skip-link-shadow);
|
||||
text-decoration: none;
|
||||
/* The pill IS the focus indicator — it appears only while focused — but the
|
||||
ring stays: on a page whose surface is close to the pill's colour the
|
||||
appearance alone is not a visible focus indicator (WCAG 2.4.11), and the
|
||||
ring is a config-level value the theme owns, never a per-component
|
||||
colour. */
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import './skip-link.css';
|
||||
/**
|
||||
* Eidos `<SkipLink>` — the bypass affordance of WCAG 2.4.1 (level A).
|
||||
*
|
||||
* <SkipLink to="app-main" />
|
||||
* <SkipLink to="app-nav" region="navigation" />
|
||||
* <SkipLink to="app-filters" region="complementary">Skip to filters</SkipLink>
|
||||
*
|
||||
* Invisible until it takes focus, then a pill against the start corner of
|
||||
* the viewport. It is the FIRST focusable thing on a page, so it is the
|
||||
* first thing a keyboard user meets: put it before everything else.
|
||||
*
|
||||
* scope:['eidos'] — no runtime. `skipLinkMorfo` declares the identity
|
||||
* (`data-skip-link`) and the sentences; this wrapper stamps them.
|
||||
*
|
||||
* WHY IT DOES NOT COMPOSE `Link`, since the tier's rule is to compose:
|
||||
* `Link` is inline ink — it inherits the ambient typography and paints an
|
||||
* underline, and its whole surface (`variant` / `underline` / `size` /
|
||||
* `color`) describes text inside a paragraph. This is not text inside a
|
||||
* paragraph; it is chrome that appears out of nowhere, with its own
|
||||
* geometry and its own ground. Composing `Link` would mean a wrapper that
|
||||
* overrides every prop of the thing it wraps — and would still leave those
|
||||
* props on the public API, promising an inline treatment that this
|
||||
* component cannot honour. Canon components render their own natives
|
||||
* (`Link` renders `<a>`, `Sidebar.MenuButton` renders `<a>`); it is BLOCKS
|
||||
* that may not (B-2).
|
||||
*/
|
||||
import { ActiveEidos } from '$uix/eidos';
|
||||
import type { SkipLinkProps } from './types';
|
||||
|
||||
let { to, region = 'main', class: className, children, ...restProps }: SkipLinkProps = $props();
|
||||
|
||||
const eidos = ActiveEidos.require();
|
||||
|
||||
const REGION_FALLBACKS = {
|
||||
main: 'Skip to main content',
|
||||
navigation: 'Skip to navigation',
|
||||
complementary: 'Skip to sidebar',
|
||||
search: 'Skip to search',
|
||||
banner: 'Skip to header',
|
||||
contentinfo: 'Skip to footer'
|
||||
} as const;
|
||||
|
||||
const label = $derived(
|
||||
eidos.langs.t(`#?components.skip-link.to-${region}|${REGION_FALLBACKS[region]}`)
|
||||
);
|
||||
|
||||
/**
|
||||
* The jump. The `href` fragment alone scrolls the target into view but
|
||||
* leaves focus on the link in every engine that does not find the target
|
||||
* focusable — so the next Tab walks back into the chrome the reader just
|
||||
* asked to skip, which is the failure this component exists to prevent.
|
||||
*
|
||||
* So the handler moves focus itself, and makes the target focusable for
|
||||
* exactly as long as that takes: `tabindex="-1"` before `focus()`, removed
|
||||
* again on blur. Leaving it behind would be harmless for the tab order but
|
||||
* dishonest in the accessibility tree — a `<main>` permanently marked
|
||||
* programmatically focusable — and this way a page never accumulates
|
||||
* attributes nobody asked for.
|
||||
*
|
||||
* The `href` stays: it is the path that still works with no JS, before
|
||||
* hydration, or if this handler ever throws.
|
||||
*/
|
||||
function jump(event: MouseEvent): void {
|
||||
const node = event.currentTarget as HTMLElement;
|
||||
const target = eidos.dom.getDocument(node).getElementById(to);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const hadTabIndex = target.hasAttribute('tabindex');
|
||||
if (!hadTabIndex) {
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.addEventListener('blur', () => target.removeAttribute('tabindex'), { once: true });
|
||||
}
|
||||
|
||||
target.focus();
|
||||
// Focus alone does not always bring the target into view when it sits
|
||||
// inside a scroll container; the scroll is what the fragment would have
|
||||
// done, so it is not an extra behaviour, it is the other half of one.
|
||||
target.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<a {...restProps} href={`#${to}`} class={className} data-skip-link="" onclick={jump}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{label}
|
||||
{/if}
|
||||
</a>
|
||||
@ -0,0 +1,39 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
/**
|
||||
* The landmark the link jumps to. The vocabulary is the APG landmark list,
|
||||
* not a list of copy: naming the ROLE is what lets the component own the
|
||||
* sentence (`morfo/components/skip-link.ts` explains why the words live
|
||||
* there). A region outside this list passes its own words as children.
|
||||
*/
|
||||
export type SkipLinkRegion =
|
||||
| 'main'
|
||||
| 'navigation'
|
||||
| 'complementary'
|
||||
| 'search'
|
||||
| 'banner'
|
||||
| 'contentinfo';
|
||||
|
||||
export type SkipLinkProps = Omit<HTMLAnchorAttributes, 'href' | 'children'> & {
|
||||
/**
|
||||
* `id` of the element to jump to — the value, not a `#` fragment. The
|
||||
* target does not have to be focusable: the component makes it focusable
|
||||
* for the jump and puts it back, so the page keeps a clean tab order.
|
||||
*/
|
||||
to: string;
|
||||
/**
|
||||
* Which landmark the target is. Picks the sentence the link shows; it is
|
||||
* not stamped on the DOM, because nothing selects on it.
|
||||
* @default 'main'
|
||||
*/
|
||||
region?: SkipLinkRegion;
|
||||
/**
|
||||
* Custom words, for a region the role vocabulary cannot name — two
|
||||
* `complementary` panels, say, where «Skip to sidebar» would name both.
|
||||
* Overrides `region`'s sentence; the app then owns that string.
|
||||
*/
|
||||
children?: Snippet;
|
||||
/** Extra class names. */
|
||||
class?: string;
|
||||
};
|
||||
@ -0,0 +1,35 @@
|
||||
import type { LangNode } from '$libs/langs';
|
||||
|
||||
export const skipLinkLangs = {
|
||||
label: {
|
||||
es: 'Enlace de salto',
|
||||
en: 'Skip link'
|
||||
},
|
||||
// One whole sentence per landmark role, never a «Skip to» prefix plus a
|
||||
// noun: Spanish contracts the article with the region («al contenido», «a
|
||||
// la navegación»), so a concatenated label is wrong half the time.
|
||||
'to-main': {
|
||||
es: 'Ir al contenido principal',
|
||||
en: 'Skip to main content'
|
||||
},
|
||||
'to-navigation': {
|
||||
es: 'Ir a la navegación',
|
||||
en: 'Skip to navigation'
|
||||
},
|
||||
'to-complementary': {
|
||||
es: 'Ir a la barra lateral',
|
||||
en: 'Skip to sidebar'
|
||||
},
|
||||
'to-search': {
|
||||
es: 'Ir a la búsqueda',
|
||||
en: 'Skip to search'
|
||||
},
|
||||
'to-banner': {
|
||||
es: 'Ir a la cabecera',
|
||||
en: 'Skip to header'
|
||||
},
|
||||
'to-contentinfo': {
|
||||
es: 'Ir al pie de página',
|
||||
en: 'Skip to footer'
|
||||
}
|
||||
} satisfies LangNode;
|
||||
@ -0,0 +1,76 @@
|
||||
import type { Morfo } from '../types';
|
||||
|
||||
/**
|
||||
* SkipLink — the bypass affordance of WCAG 2.4.1 (Bypass Blocks, level A): a
|
||||
* link that is invisible until it takes focus, and that jumps sequential
|
||||
* navigation past the chrome repeated on every page. Built as F1 of the
|
||||
* `app-shell` phase 0 (`docs/process/PLAN-blocks.md` §F3.1, decision Q4),
|
||||
* because the tier had already decided in F2.1 that «the shells own it» and
|
||||
* there was nothing for a shell to compose.
|
||||
*
|
||||
* scope: ['eidos'] — no runtime. There is no state to hold and nothing to
|
||||
* observe: it is a link with one behaviour (move focus to a region) that runs
|
||||
* inside its own click handler. `Sticky` earns a soma layer with
|
||||
* IntersectionObserver; here there is nothing to detect. 0-event
|
||||
* justification lives in the README (`## Passive justification`).
|
||||
*
|
||||
* WHY THE WORDS LIVE HERE, and this is the part that matters. A skip link is
|
||||
* one of the few components whose whole content is a sentence the product
|
||||
* never writes: «Skip to main content» is not copy, it is the name of an
|
||||
* accessibility contract, and the reason blocks may not own strings (B-7 /
|
||||
* D-BLK.5) points at exactly this door — «if a string looks unavoidable, it is
|
||||
* contract surface and the canonical component owns it, through `texts:` +
|
||||
* langs». So the vocabulary is keyed by LANDMARK ROLE (the APG list), each key
|
||||
* a whole sentence rather than a «Skip to» prefix glued to a noun: in Spanish
|
||||
* the article changes with the region («al contenido principal», «a la
|
||||
* navegación»), and a concatenated label would be wrong in half the catalogue.
|
||||
* A region with no role in this list — two `complementary` panels that need
|
||||
* telling apart — passes its own words as children.
|
||||
*
|
||||
* WHAT THIS MORFO DOES NOT DECLARE: no `data-region`. The region picks a text
|
||||
* and nothing else; no layer selects on it. Declaring it would repeat the
|
||||
* mistake `Affix` shipped for a day and corrected on 2026-08-15 (morfo is the
|
||||
* contract BETWEEN layers, so an attribute a single layer reads — or here,
|
||||
* none — has no business in it).
|
||||
*/
|
||||
export const skipLinkMorfo = {
|
||||
name: 'SkipLink',
|
||||
kebab: 'skip-link',
|
||||
scope: ['eidos'],
|
||||
apg: 'none — a link; WCAG 2.4.1 technique G124 (links to each area of the content), no widget pattern applies',
|
||||
texts: {
|
||||
label: '#?components.skip-link.label|Skip link',
|
||||
// One sentence per landmark role of the APG list. The key is the role
|
||||
// so the caller names a CONTRACT, not a piece of copy.
|
||||
'to-main': '#?components.skip-link.to-main|Skip to main content',
|
||||
'to-navigation': '#?components.skip-link.to-navigation|Skip to navigation',
|
||||
'to-complementary': '#?components.skip-link.to-complementary|Skip to sidebar',
|
||||
'to-search': '#?components.skip-link.to-search|Skip to search',
|
||||
'to-banner': '#?components.skip-link.to-banner|Skip to header',
|
||||
'to-contentinfo': '#?components.skip-link.to-contentinfo|Skip to footer'
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
// The anchor itself. `kebab: 'provider'` → bare `data-skip-link`.
|
||||
// `<a>` and not a button: the destination is a place in the document,
|
||||
// so the browser's own fragment navigation stays the fallback when
|
||||
// the handler never runs (no JS, an error before hydration).
|
||||
// `href` carries the `#{to}` fragment, written by the wrapper. It is
|
||||
// not declared as contract because no layer selects on it — it is
|
||||
// the FALLBACK path: without JS, or before hydration, the browser's
|
||||
// own fragment navigation still moves the reader. The handler exists
|
||||
// on top of it because a fragment scrolls but leaves focus behind on
|
||||
// most engines unless the target is focusable.
|
||||
name: 'Provider',
|
||||
kebab: 'provider',
|
||||
archetype: 'provider',
|
||||
kind: 'public',
|
||||
defaultElement: 'a',
|
||||
optional: false,
|
||||
data: [],
|
||||
// The accessible name IS the visible sentence, so there is nothing to
|
||||
// add. Empty on purpose: a decision, not an omission.
|
||||
aria: []
|
||||
}
|
||||
]
|
||||
} as const satisfies Morfo;
|
||||
@ -0,0 +1,649 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SkipLink — the bypass affordance of WCAG 2.4.1.
|
||||
*
|
||||
* The stage is the hard part of this demo and it is not decoration: a skip
|
||||
* link is INVISIBLE until it takes focus, so a screenshot of it doing its
|
||||
* job is a screenshot of nothing. The stage therefore ships a real chrome —
|
||||
* a strip, a header, a nav of five links — and a Tab counter, because the
|
||||
* whole claim of the component is arithmetic: how many tabs it takes to
|
||||
* reach the content with the link and without it.
|
||||
*/
|
||||
import { SkipLink, type SkipLinkRegion } from '$uix/eidos/components/skip-link';
|
||||
import { compileMorfo } from '$uix/morfo';
|
||||
import { skipLinkMorfo } from '@/uix/morfo/components/skip-link';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
|
||||
import SystemAxes from '../../lib/SystemAxes.svelte';
|
||||
import MotionPanel from '../../lib/MotionPanel.svelte';
|
||||
import SemaPanel from '../../lib/SemaPanel.svelte';
|
||||
import { DemoTrace } from '../../lib/harness.svelte';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
// prettier-ignore
|
||||
type Tab = 'live' | 'system' | 'motion' | 'sema' | 'services' | 'api' | 'morfo' | 'recipe' | 'a11y';
|
||||
let tab = $state<Tab>('live');
|
||||
|
||||
// ── Stage + System axes ──────────────────────────────────────────────
|
||||
const trace = new DemoTrace();
|
||||
let stageRef = $state<HTMLElement | null>(null);
|
||||
$effect(() => {
|
||||
if (stageRef) return trace.observe(stageRef);
|
||||
});
|
||||
let density = $state('comfortable');
|
||||
let scaling = $state(1);
|
||||
let mode = $state<'inherit' | 'light' | 'dark'>('inherit');
|
||||
let dirMode = $state<'auto' | 'ltr' | 'rtl'>('auto');
|
||||
const dir = $derived(dirMode === 'auto' ? undefined : dirMode);
|
||||
let borderWidth = $state(1);
|
||||
|
||||
// ── Live state ───────────────────────────────────────────────────────
|
||||
const regions: SkipLinkRegion[] = [
|
||||
'main',
|
||||
'navigation',
|
||||
'complementary',
|
||||
'search',
|
||||
'banner',
|
||||
'contentinfo'
|
||||
];
|
||||
let region = $state<SkipLinkRegion>('main');
|
||||
/**
|
||||
* How many links the stage renders. `1` is technique G1 (a link to the main
|
||||
* content); `3` is G124 (a link to each area). Both are sufficient for
|
||||
* 2.4.1 — the component is the same; what changes is what the page composes.
|
||||
*/
|
||||
let links = $state<1 | 3>(1);
|
||||
/** Demo-only: custom words, the escape hatch for a region the roles cannot name. */
|
||||
let customWords = $state(false);
|
||||
|
||||
/**
|
||||
* The stage's own bookkeeping. `tabIndexInStage` counts how deep into the
|
||||
* chrome the focus currently is, so the claim — «one tab instead of eight» —
|
||||
* is on screen instead of in a paragraph.
|
||||
*/
|
||||
let focusedLabel = $state('—');
|
||||
let landedOn = $state('—');
|
||||
let tabCount = $state(0);
|
||||
|
||||
function noteFocus(label: string): void {
|
||||
focusedLabel = label;
|
||||
tabCount += 1;
|
||||
}
|
||||
|
||||
function resetCount(): void {
|
||||
tabCount = 0;
|
||||
focusedLabel = '—';
|
||||
landedOn = '—';
|
||||
}
|
||||
|
||||
// ── Compiled morfo ───────────────────────────────────────────────────
|
||||
const compiled = compileMorfo(skipLinkMorfo);
|
||||
const partsList = [...compiled.parts.byKebab.values()];
|
||||
const events = [...compiled.actions.byName.values()];
|
||||
|
||||
const eidosSnippet = $derived(
|
||||
[
|
||||
"<script lang='ts'>",
|
||||
" import { SkipLink } from '$uix/eidos/components/skip-link';",
|
||||
'</' + 'script>',
|
||||
'',
|
||||
'<!-- FIRST focusable thing on the page -->',
|
||||
links === 1
|
||||
? `<SkipLink to="app-main"${region === 'main' ? '' : ` region="${region}"`} />`
|
||||
: [
|
||||
'<SkipLink to="app-main" />',
|
||||
'<SkipLink to="app-nav" region="navigation" />',
|
||||
'<SkipLink to="app-aside" region="complementary" />'
|
||||
].join('\n'),
|
||||
'',
|
||||
'<header id="app-banner">…</header>',
|
||||
'<nav id="app-nav" aria-label="Primary">…</nav>',
|
||||
'<main id="app-main">…</main>',
|
||||
'',
|
||||
customWords
|
||||
? '<!-- a region the role vocabulary cannot name: the app owns those words -->\n<SkipLink to="app-filters" region="complementary">Skip to filters</SkipLink>'
|
||||
: '<!-- the words come from langs; the app names a CONTRACT, not copy -->'
|
||||
].join('\n')
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet chromeLink(label: string)}
|
||||
<a
|
||||
href="#/"
|
||||
onclick={(e) => e.preventDefault()}
|
||||
onfocus={() => noteFocus(`chrome · ${label}`)}
|
||||
style="color: var(--color-content-muted); text-decoration: none; padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm);"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div data-uix-canvas-inner>
|
||||
<header>
|
||||
<div data-uix-eyebrow>Navigation · SkipLink</div>
|
||||
<h1 data-uix-page-title>SkipLink</h1>
|
||||
<p data-uix-page-lede>
|
||||
The bypass affordance of <strong>WCAG 2.4.1 (level A)</strong>: invisible until it takes
|
||||
focus, then a pill against the start corner of the viewport. It jumps sequential navigation
|
||||
past the chrome every page repeats — and it moves the FOCUS, which a bare
|
||||
<code>#fragment</code>
|
||||
does not. The words are the component's, by landmark role, because «Skip to main content» is not
|
||||
copy: it is the name of an accessibility contract.
|
||||
</p>
|
||||
<div data-uix-page-meta>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>events</span>{events.length}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>technique</span>{links === 1 ? 'G1' : 'G124'}
|
||||
</span>
|
||||
<span data-uix-meta-pill>
|
||||
<span data-uix-meta-key>scope</span>eidos
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Always-on stage — a page's worth of chrome, so the link has something to skip -->
|
||||
<div data-uix-stage>
|
||||
<div
|
||||
data-uix-stage-area
|
||||
bind:this={stageRef}
|
||||
data-density={density}
|
||||
data-theme={mode === 'inherit' ? undefined : mode}
|
||||
data-mode={mode === 'inherit' ? undefined : mode}
|
||||
{...dir ? { dir } : {}}
|
||||
style={`--scaling: ${scaling}; --border-width: ${borderWidth}px; display: block;`}
|
||||
>
|
||||
<!--
|
||||
The starting line goes BEFORE the frame, and that is not layout
|
||||
taste: a skip link works by TAB ORDER, so a «press Tab from here»
|
||||
button placed after the chrome would send focus forward, past
|
||||
everything the link exists to skip. Measured with Playwright the
|
||||
first time it was written the other way round — the first four tab
|
||||
stops were the docs shell's own chrome, not this stage.
|
||||
-->
|
||||
<div style="margin-block-end: var(--space-3);">
|
||||
<button
|
||||
type="button"
|
||||
onclick={resetCount}
|
||||
onfocus={() => (focusedLabel = 'start here — press Tab')}
|
||||
style="padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); border: 1px solid var(--color-border-default); background: var(--color-surface-raised, transparent); cursor: pointer;"
|
||||
>
|
||||
Start here · press Tab, then Enter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
`contain: layout` makes the stage the containing block for the
|
||||
focused pill: without it the fixed box flies to the corner of the
|
||||
PAGE, over the docs chrome, and the demo shows the component
|
||||
escaping its own frame. Same mechanism the Affix demo teaches.
|
||||
-->
|
||||
<div
|
||||
style="contain: layout; position: relative; overflow: hidden; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-default);"
|
||||
>
|
||||
<!-- The skip links: FIRST, before any chrome -->
|
||||
{#if customWords}
|
||||
<SkipLink to="demo-aside" region="complementary" onfocus={() => noteFocus('skip link')}>
|
||||
Skip to filters
|
||||
</SkipLink>
|
||||
{:else if links === 1}
|
||||
<SkipLink to="demo-main" {region} onfocus={() => noteFocus('skip link')} />
|
||||
{:else}
|
||||
<SkipLink to="demo-main" onfocus={() => noteFocus('skip link · main')} />
|
||||
<SkipLink
|
||||
to="demo-nav"
|
||||
region="navigation"
|
||||
onfocus={() => noteFocus('skip link · nav')}
|
||||
/>
|
||||
<SkipLink
|
||||
to="demo-aside"
|
||||
region="complementary"
|
||||
onfocus={() => noteFocus('skip link · aside')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
id="demo-banner"
|
||||
onfocus={() => (landedOn = 'banner')}
|
||||
style="display: flex; gap: var(--space-2); align-items: center; padding: var(--space-2) var(--space-3); background: var(--color-neutral-track); font-size: var(--font-size-sm);"
|
||||
>
|
||||
<strong>Chrome</strong>
|
||||
<span style="color: var(--color-content-muted);">the part a reader asks to skip</span>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
id="demo-nav"
|
||||
aria-label="Demo"
|
||||
onfocus={() => (landedOn = 'navigation')}
|
||||
style="display: flex; gap: var(--space-1); flex-wrap: wrap; padding: var(--space-2) var(--space-3); border-block-end: 1px solid var(--color-border-subtle);"
|
||||
>
|
||||
{@render chromeLink('Overview')}
|
||||
{@render chromeLink('Guides')}
|
||||
{@render chromeLink('Components')}
|
||||
{@render chromeLink('Blocks')}
|
||||
{@render chromeLink('Changelog')}
|
||||
</nav>
|
||||
|
||||
<div style="display: flex; gap: var(--space-3); padding: var(--space-3);">
|
||||
<main
|
||||
id="demo-main"
|
||||
onfocus={() => (landedOn = 'main')}
|
||||
style="flex: 1 1 auto; min-inline-size: 0;"
|
||||
>
|
||||
<h3 style="margin: 0 0 var(--space-2); font-size: var(--font-size-md);">
|
||||
Main content
|
||||
</h3>
|
||||
<p
|
||||
style="margin: 0; color: var(--color-content-muted); font-size: var(--font-size-sm);"
|
||||
>
|
||||
From the button above: one <kbd>Tab</kbd> reaches the skip link, <kbd>Enter</kbd> lands
|
||||
here. Measured. Keep tabbing instead and you get the other half of the lesson — five chrome
|
||||
links, and then the focus leaves the stage entirely: this paragraph is not a tab stop, so
|
||||
sequential navigation never LANDS on the content at all. That is why the jump makes its
|
||||
target focusable for the length of the jump.
|
||||
</p>
|
||||
</main>
|
||||
<aside
|
||||
id="demo-aside"
|
||||
aria-label="Demo filters"
|
||||
onfocus={() => (landedOn = 'complementary')}
|
||||
style="flex: 0 0 10rem; padding: var(--space-2); background: var(--color-surface-muted); border-radius: var(--radius-sm); font-size: var(--font-size-sm); color: var(--color-content-muted);"
|
||||
>
|
||||
Filters
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-uix-stage-trace>
|
||||
<span data-uix-stage-trace-key>focus</span>
|
||||
<span><span data-uix-stage-trace-event>{focusedLabel}</span></span>
|
||||
<span style="color: var(--uix-text-faint)">·</span>
|
||||
<span data-uix-stage-trace-key>tabs</span>
|
||||
<span>{tabCount}</span>
|
||||
<span style="margin-inline-start: auto;">
|
||||
<span data-uix-stage-trace-key>landed on</span>
|
||||
{landedOn} ·
|
||||
<span data-uix-stage-trace-key>events</span>
|
||||
{events.length === 0 ? 'none — navigation is mute' : events.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-uix-tabs role="tablist">
|
||||
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
|
||||
<button data-uix-tab data-active={tab === 'system'} onclick={() => (tab = 'system')}>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> System
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'motion'} onclick={() => (tab = 'motion')}>
|
||||
Motion
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
|
||||
<span data-uix-layer-badge="sema">sema</span>
|
||||
<span data-uix-tab-count>{events.length}</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'services'} onclick={() => (tab = 'services')}>
|
||||
Services
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
|
||||
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
|
||||
<span data-uix-layer-badge="morfo">morfo</span>
|
||||
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>
|
||||
Recipe
|
||||
</button>
|
||||
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'live'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Controls</h2>
|
||||
<p data-uix-section-desc>
|
||||
Three props and no variants. <code>region</code> picks the SENTENCE (it is not stamped on
|
||||
the DOM — nothing selects on it); <code>to</code> is the target's
|
||||
<code>id</code>, not a <code>#</code> fragment; children override the words for a region the role
|
||||
vocabulary cannot name.
|
||||
</p>
|
||||
|
||||
<div data-uix-subsection-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span> props
|
||||
</div>
|
||||
<div data-uix-controls>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>
|
||||
region <span data-uix-control-hint>the APG landmark list — picks the sentence</span>
|
||||
</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
{#each regions as r (r)}
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={region === r}
|
||||
disabled={links === 3 || customWords}
|
||||
onclick={() => (region = r)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>
|
||||
how many links
|
||||
<span data-uix-control-hint>1 = technique G1 · 3 = G124 (one per area)</span>
|
||||
</span>
|
||||
<span data-uix-chips role="radiogroup">
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={links === 1}
|
||||
disabled={customWords}
|
||||
onclick={() => (links = 1)}>1 · to main</button
|
||||
>
|
||||
<button
|
||||
data-uix-chip
|
||||
data-active={links === 3}
|
||||
disabled={customWords}
|
||||
onclick={() => (links = 3)}>3 · one per region</button
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<label data-uix-control>
|
||||
<span data-uix-control-label>
|
||||
children <span data-uix-control-hint>custom words — the app owns that string</span>
|
||||
</span>
|
||||
<span data-uix-switch>
|
||||
<input type="checkbox" bind:checked={customWords} />
|
||||
<span data-uix-switch-label>{customWords ? 'on' : 'off'}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc>
|
||||
<strong>Read the sentence in the current language.</strong> The label above resolves through
|
||||
langs, so switching the shell's language switches the link:
|
||||
<code>{uix.langs.t(`#?components.skip-link.to-${region}|Skip to…`)}</code>. That is the
|
||||
whole argument for the words living in the component — a product that has to write «Ir al
|
||||
contenido principal» itself will write it in one language.
|
||||
</p>
|
||||
|
||||
<div data-uix-code>
|
||||
<div data-uix-code-head>
|
||||
<span data-uix-layer-badge="eidos">eidos</span>
|
||||
<span>first focusable element on the page</span>
|
||||
<span data-uix-code-lang>svelte</span>
|
||||
</div>
|
||||
<pre><code>{eidosSnippet}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'system'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>System axes</h2>
|
||||
<p data-uix-section-desc>
|
||||
The pill is placed with LOGICAL insets (<code>inset-inline-start</code>), so
|
||||
<code>dir: rtl</code> mirrors it to the right corner with no second rule and no
|
||||
<code>[dir]</code> selector. Density and scaling reach its padding through the space scale; mode
|
||||
reaches its ground through the colour roles.
|
||||
</p>
|
||||
<SystemAxes bind:density bind:scaling bind:mode bind:dir={dirMode} bind:borderWidth />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'motion'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Motion</h2>
|
||||
<MotionPanel
|
||||
note="SkipLink has no motion prop, and no transition on purpose: it appears the instant it takes focus. Animating the arrival would delay the one affordance whose value is being there immediately — and an entrance a reader has to wait for is worse than none under prefers-reduced-motion."
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'sema'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>
|
||||
<span data-uix-layer-badge="sema">sema</span> · events
|
||||
</h2>
|
||||
<p data-uix-section-desc>
|
||||
SkipLink declares no semantic events, and the reason is the system's own rule rather than an
|
||||
omission: <strong>navigation is mute here</strong>. A row of the
|
||||
<code>Sidebar</code>
|
||||
that navigates does not sound either, and neither does <code>Link</code>. The perceptible
|
||||
act belongs to what the reader does after ARRIVING, never to the jump.
|
||||
</p>
|
||||
<SemaPanel
|
||||
actions={events}
|
||||
{uix}
|
||||
getTarget={() => stageRef?.querySelector('[data-skip-link]') ?? stageRef}
|
||||
/>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'services'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Services</h2>
|
||||
<p data-uix-section-desc>
|
||||
Two. <strong>langs</strong> resolves the sentence (<code>#?components.skip-link.to-*</code>)
|
||||
— the component owns the words because they are contract, not copy. <strong>adom</strong>
|
||||
resolves the document the target lives in (<code>uix.dom.getDocument(node)</code>, never the
|
||||
global <code>document</code>: in an iframe, a popup or a happy-dom test the global one is
|
||||
the wrong document). No format, no clipboard, no announce: the focus move IS the
|
||||
announcement — the screen reader reads the region it lands in.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'api'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>API reference</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">to</td>
|
||||
<td class="type">string (required)</td>
|
||||
<td>
|
||||
The target's <code>id</code> — the value, not a <code>#</code> fragment. The target does
|
||||
not need to be focusable: the component makes it focusable for the jump and puts it back
|
||||
on blur.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">region</td>
|
||||
<td class="type">main | navigation | complementary | search | banner | contentinfo</td
|
||||
>
|
||||
<td>
|
||||
Which landmark the target is. Picks the sentence; <strong>not</strong> stamped on
|
||||
the DOM. Default <code>main</code>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">children</td>
|
||||
<td class="type">Snippet</td>
|
||||
<td>
|
||||
Custom words, for a region the role vocabulary cannot name (two
|
||||
<code>complementary</code> panels). Overrides <code>region</code>'s sentence — and
|
||||
then the app owns that string.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>
|
||||
<strong>Emits</strong> <code>data-skip-link</code> on the anchor, plus the
|
||||
<code>href="#{'{to}'}"</code> fallback. Everything else passes through to the
|
||||
<code><a></code>.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'morfo'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Morfo contract</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Field</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="name">name</td><td>{skipLinkMorfo.name}</td></tr>
|
||||
<tr><td class="name">kebab</td><td><code>{skipLinkMorfo.kebab}</code></td></tr>
|
||||
<tr><td class="name">scope</td><td>{skipLinkMorfo.scope.join(', ')}</td></tr>
|
||||
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
|
||||
<tr><td class="name">events</td><td>{events.length}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Parts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>kebab</th><th>marker</th><th>element</th><th>archetype</th><th>optional</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each partsList as part (part.kebab)}
|
||||
<tr>
|
||||
<td class="name">{part.kebab}</td>
|
||||
<td><code data-uix-part-marker>[{part.marker}]</code></td>
|
||||
<td class="type"><{part.defaultElement}></td>
|
||||
<td class="type">{part.archetype ?? '—'}</td>
|
||||
<td class="default">{part.optional ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-uix-subsection-head>Texts</div>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>key</th><th>idlangref</th><th>resolved</th></tr></thead>
|
||||
<tbody>
|
||||
{#each Object.entries(skipLinkMorfo.texts) as [key, ref] (key)}
|
||||
<tr>
|
||||
<td class="name">{key}</td>
|
||||
<td class="type"><code>{ref}</code></td>
|
||||
<td>{uix.langs.t(ref)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p data-uix-section-desc style="margin-top: var(--uix-space-4);">
|
||||
One part and no data-attrs beyond the identity. <code>region</code> is deliberately NOT
|
||||
declared: it selects a text and no layer reads it, and morfo is the contract BETWEEN layers
|
||||
— the same correction <code>Affix</code> made on 2026-08-15 after shipping its placement knobs
|
||||
here for a day.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'recipe'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Eidos recipe</h2>
|
||||
<p data-uix-section-desc>
|
||||
<code>src/uix/eidos/components/skip-link/skip-link.css</code> — two states and nothing in between.
|
||||
</p>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name"><code>[data-skip-link]</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>
|
||||
sr-only: in the tab order and in the accessibility tree, out of the picture. Not
|
||||
<code>display:none</code>/<code>visibility:hidden</code>/negative
|
||||
<code>tabindex</code> — all three would remove the one thing it exists to be in.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name"><code>[data-skip-link]:focus</code></td>
|
||||
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
|
||||
<td>
|
||||
The pill: <code>position: fixed</code> on logical insets (RTL mirrors for free),
|
||||
<code>--skip-link-z-index</code> (950, the top of the static ladder), the colour roles,
|
||||
and the focus ring — which stays, because on a surface close to the pill's ground «appearing»
|
||||
is not a visible focus indicator (2.4.11).
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p data-uix-section-desc>
|
||||
Tokens: <code>--skip-link-z-index</code>, <code>--skip-link-offset</code>,
|
||||
<code>--skip-link-padding-block</code>, <code>--skip-link-padding-inline</code>,
|
||||
<code>--skip-link-radius</code>, <code>--skip-link-bg</code>,
|
||||
<code>--skip-link-fg</code>, <code>--skip-link-shadow</code>.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if tab === 'a11y'}
|
||||
<section data-uix-section>
|
||||
<h2 data-uix-section-title>Accessibility</h2>
|
||||
<div data-uix-table-wrap>
|
||||
<table data-uix-table>
|
||||
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="name">Success criterion</td>
|
||||
<td>
|
||||
<strong>WCAG 2.4.1 Bypass Blocks (A)</strong>. One link to the main content is
|
||||
technique <strong>G1</strong>; one per area is <strong>G124</strong>. Both are
|
||||
sufficient — which one you get is what the page composes, not a prop.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Order</td>
|
||||
<td>
|
||||
It has to be the FIRST focusable element, or it is skipping nothing. The stage above
|
||||
renders it before any chrome; a shell renders it before its regions.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Focus, not scroll</td>
|
||||
<td>
|
||||
A bare <code>#fragment</code> scrolls but leaves focus on the link in every engine
|
||||
that does not find the target focusable — so the next Tab walks straight back into
|
||||
the chrome. The handler moves focus itself and cleans up the temporary
|
||||
<code>tabindex="-1"</code> on blur.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Name</td>
|
||||
<td>
|
||||
The visible sentence IS the accessible name — no <code>aria-label</code>, nothing to
|
||||
go out of sync. Localised through langs by landmark role.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Visible focus</td>
|
||||
<td>
|
||||
<code>:focus</code>, not only <code>:focus-visible</code>: it must also appear when
|
||||
an app moves focus to it programmatically (after a route change). The ring is kept
|
||||
on top of the pill (2.4.11).
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="name">Never covered</td>
|
||||
<td>
|
||||
Its own z rung (<code>--z-index-skip-link: 950</code>), above every piece of page
|
||||
chrome. It cannot share the <code>affix</code> rung: ties break by DOM order and this
|
||||
link is by definition first, so it would lose to any affixed notice — the measured A-95
|
||||
failure.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in new issue