Compare commits

...

1 Commits

Author SHA1 Message Date
Teffen Ellis
155b13f35d web/a11y: Accessible preference classes. 2025-11-20 18:48:56 +01:00
4 changed files with 218 additions and 5 deletions

142
web/src/common/a11y.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* @file Accessibility utilities.
*/
import { createMediaQueryEffect } from "#common/matchers";
//#region Constants
/**
* Class names applied to the document element based on user accessibility preferences.
*
* @category Accessibility
*/
export const A11YClassName = {
ReduceMotion: "ak-m-reduce-motion",
ReduceTransparency: "ak-m-reduce-transparency",
MoreContrast: "ak-m-more-contrast",
LessContrast: "ak-m-less-contrast",
} as const;
export type A11YClassName = (typeof A11YClassName)[keyof typeof A11YClassName];
//#endregion
//#region Effects
/**
* Create an effect that applies the user's motion preference to the document.
*
* ```ts
* const dispose = createMotionPreferenceEffect();
* ```
*
* @category Accessibility
*/
export function createMotionPreferenceEffect() {
const dispose = createMediaQueryEffect(
"(prefers-reduced-motion: reduce)",
(event) => {
document.documentElement.classList.toggle(A11YClassName.ReduceMotion, event.matches);
},
{
immediate: true,
},
);
return dispose;
}
/**
* Create an effect that applies the user's motion preference to the document.
*
* ```ts
* const dispose = createMotionPreferenceEffect();
* ```
*
* @category Accessibility
*/
export function createContrastPreferenceEffect() {
const dispose = createMediaQueryEffect(
"(prefers-contrast: more)",
(event) => {
document.documentElement.classList.toggle(A11YClassName.MoreContrast, event.matches);
},
{
immediate: true,
},
);
return dispose;
}
/**
* Create an effect that applies the user's motion preference to the document.
*
* ```ts
* const dispose = createMotionPreferenceEffect();
* ```
*
* @category Accessibility
*/
export function createTransparencyPreferenceEffect() {
const dispose = createMediaQueryEffect(
"(prefers-reduced-transparency: reduce)",
(event) => {
document.documentElement.classList.toggle(
A11YClassName.ReduceTransparency,
event.matches,
);
},
{
immediate: true,
},
);
return dispose;
}
//#endregion
//#region Predicates
/**
* Predicate to determine if the user agent indicates a preference for reduced motion.
*
* ```ts
* const prefersReducedMotion = isReducedMotionPreferred();
* ```
*
* @category Accessibility
*/
export function isReducedMotionPreferred(): boolean {
return document.documentElement.classList.contains(A11YClassName.ReduceMotion);
}
/**
* Predicate to determine if the user agent indicates a preference for reduced transparency.
*
* ```ts
* const prefersReducedTransparency = isReducedTransparencyPreferred();
* ```
*
* @category Accessibility
*/
export function isReducedTransparencyPreferred(): boolean {
return document.documentElement.classList.contains(A11YClassName.ReduceTransparency);
}
/**
* Predicate to determine if the user agent indicates a preference for more contrast.
*
* ```ts
* const prefersMoreContrast = isMoreContrastPreferred();
* ```
*
* @category Accessibility
*/
export function isMoreContrastPreferred(): boolean {
return document.documentElement.classList.contains(A11YClassName.MoreContrast);
}
//#endregion

View File

@@ -0,0 +1,59 @@
/**
* Invoked when cleanup is required.
*/
export type MediaMatcherDispose = () => void;
/**
* Callback invoked when a media query match changes.
*/
export type MediaChangeEffect = (event: MediaQueryListEvent) => void;
export interface MediaQueryEffectListenerOptions extends AddEventListenerOptions {
/**
* Whether to invoke the effect immediately upon creation.
*/
immediate?: boolean;
}
/**
* Create an effect that runs on a media query match.
*
* @param query The media query to match.
* @param effect The callback to run when the media query matches.
* @param listenerOptions Options for the event listener.
*
* @returns A cleanup function that removes the effect.
*/
export function createMediaQueryEffect(
query: string | MediaQueryList,
effect: MediaChangeEffect,
listenerOptions?: MediaQueryEffectListenerOptions,
): MediaMatcherDispose {
const mediaQueryList = typeof query === "string" ? window.matchMedia(query) : query;
// First, wrap the effect to ensure we can abort it.
const mediaChangeListener = (event: MediaQueryListEvent) => {
if (listenerOptions?.signal?.aborted) {
return;
}
effect(event);
};
mediaQueryList.addEventListener("change", mediaChangeListener, listenerOptions);
const dispose: MediaMatcherDispose = () => {
mediaQueryList.removeEventListener("change", mediaChangeListener);
};
if (listenerOptions?.immediate) {
effect(
new MediaQueryListEvent("change", {
matches: mediaQueryList.matches,
media: mediaQueryList.media,
}),
);
}
return dispose;
}

View File

@@ -2,6 +2,7 @@
* @file Theme utilities.
*/
import { createMediaQueryEffect, MediaChangeEffect } from "#common/matchers";
import { setAdoptedStyleSheets, type StyleRoot } from "#common/stylesheets";
import { UiThemeEnum } from "@goauthentik/api";
@@ -156,9 +157,7 @@ export function createUIThemeEffect(
const mediaQueryList = createColorSchemeTarget(colorSchemeTarget);
// First, wrap the effect to ensure we can abort it.
const mediaChangeListener = (event: MediaQueryListEvent) => {
if (listenerOptions?.signal?.aborted) return;
const mediaChangeListener: MediaChangeEffect = (event) => {
const { themeChoice, theme: previousTheme } = document.documentElement.dataset;
if (themeChoice && themeChoice !== "auto") {
@@ -197,12 +196,16 @@ export function createUIThemeEffect(
});
// Listen for changes to the color scheme...
mediaQueryList.addEventListener("change", mediaChangeListener);
const disposeChangeListener = createMediaQueryEffect(
mediaQueryList,
mediaChangeListener,
listenerOptions,
);
// Finally, allow the caller to remove the effect.
const cleanup = () => {
documentObserver.disconnect();
mediaQueryList.removeEventListener("change", mediaChangeListener);
disposeChangeListener();
};
listenerOptions?.signal?.addEventListener("abort", cleanup);

View File

@@ -1,3 +1,8 @@
import {
createContrastPreferenceEffect,
createMotionPreferenceEffect,
createTransparencyPreferenceEffect,
} from "#common/a11y";
import { globalAK } from "#common/global";
import { applyDocumentTheme, createUIThemeEffect } from "#common/theme";
@@ -20,6 +25,10 @@ export abstract class Interface extends AKElement {
this.addController(new ConfigContextController(this, config));
this.addController(new BrandingContextController(this, brand));
this.addController(new ModalOrchestrationController());
createMotionPreferenceEffect();
createContrastPreferenceEffect();
createTransparencyPreferenceEffect();
}
public connectedCallback(): void {