Skip to content

Commit 42b96e8

Browse files
authored
web/a11y: User library -- fix issues surrounding element focus, ARIA labeling. (#17522)
* web/a11y: Fix issues surrounding element focus, aria labeling. * web: Fix focus * web: Fix nested focus * web: Fix menu visibility when anchor positioning is not supported. * web: Fix icon fallback behavior, labels. * web: Fix flickering, descriptions. * web: Fix excess width on mobile. * web: Fix rendering artifacts on mobile. * web: Remove aria-controls behavior. - This is buggy, similar to aria-owns, and may cause crashes. * web: Fix tabpanel focus attempting to scroll page. * web: Fix issues surrounding consistent tab panel parameter testing. * web: add shared helpers. * web: Tidy comments.
1 parent 7891046 commit 42b96e8

22 files changed

+910
-859
lines changed

web/src/admin/Routes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import "#admin/admin-overview/AdminOverviewPage";
22

3+
import { globalAK } from "#common/global";
4+
35
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "#elements/router/Route";
46

57
import { html } from "lit";
@@ -158,3 +160,14 @@ export const ROUTES: Route[] = [
158160
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
159161
}),
160162
];
163+
164+
/**
165+
* Application route helpers.
166+
*
167+
* @TODO: This API isn't quite right yet. Revisit after the hash router is replaced.
168+
*/
169+
export const ApplicationRoute = {
170+
EditURL(slug: string, base = globalAK().api.base) {
171+
return `${base}if/admin/#/core/applications/${slug}`;
172+
},
173+
} as const;

web/src/common/styles/authentik.css

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,17 @@ html > form > input {
182182
overflow: hidden;
183183
}
184184

185+
@media not (prefers-contrast: more) {
186+
.less-contrast-sr-only {
187+
position: absolute;
188+
left: -10000px;
189+
top: auto;
190+
width: 1px;
191+
height: 1px;
192+
overflow: hidden;
193+
}
194+
}
195+
185196
/* #endregion */
186197

187198
/* #region Icons */
@@ -668,7 +679,8 @@ fieldset {
668679
}
669680

670681
.pf-c-form__helper-text {
671-
text-wrap: pretty;
682+
text-wrap: balance;
683+
text-wrap: pretty; /* Supporting browsers. */
672684
}
673685

674686
::placeholder {

web/src/elements/AppIcon.css

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
:host {
2+
--icon-border: 0;
3+
4+
--app-icon-shadow-blend-color: color-mix(
5+
in srgb,
6+
var(--app-icon--shadow-background-color, var(--pf-global--BackgroundColor--150)) 100%,
7+
black 100%
8+
);
9+
10+
display: flex;
11+
place-content: center;
12+
13+
height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
14+
width: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
15+
}
16+
17+
:host([size="pf-m-lg"]) {
18+
--icon-height: 4rem;
19+
--icon-border: 0.25rem;
20+
}
21+
22+
:host([size="pf-m-md"]) {
23+
--icon-height: 2rem;
24+
--icon-border: 0.125rem;
25+
}
26+
27+
:host([size="pf-m-sm"]) {
28+
--icon-height: 1rem;
29+
--icon-border: 0.125rem;
30+
}
31+
32+
:host([size="pf-m-xl"]) {
33+
--icon-height: 6rem;
34+
--icon-border: 0.25rem;
35+
}
36+
37+
.icon {
38+
font-size: var(--icon-font-size, var(--icon-height));
39+
color: var(--ak-global--Color--100);
40+
padding: var(--icon-border);
41+
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
42+
line-height: 1;
43+
filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color));
44+
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
45+
}

web/src/elements/AppIcon.ts

Lines changed: 41 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,64 @@
11
import { PFSize } from "#common/enums";
22

3+
import Styles from "#elements/AppIcon.css";
34
import { AKElement } from "#elements/Base";
45

5-
import { match, P } from "ts-pattern";
6-
7-
import { msg } from "@lit/localize";
8-
import { css, CSSResult, html, TemplateResult } from "lit";
6+
import { msg, str } from "@lit/localize";
7+
import { CSSResult, html, TemplateResult } from "lit";
98
import { customElement, property } from "lit/decorators.js";
109

1110
import PFFAIcons from "@patternfly/patternfly/base/patternfly-fa-icons.css";
12-
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
1311

1412
export interface IAppIcon {
15-
name?: string;
16-
icon?: string;
17-
size?: PFSize;
13+
name?: string | null;
14+
icon?: string | null;
15+
size?: PFSize | null;
1816
}
1917

2018
@customElement("ak-app-icon")
2119
export class AppIcon extends AKElement implements IAppIcon {
20+
public static readonly FontAwesomeProtocol = "fa://";
21+
22+
static styles: CSSResult[] = [PFFAIcons, Styles];
23+
2224
@property({ type: String })
23-
name?: string;
25+
public name: string | null = null;
2426

2527
@property({ type: String })
26-
icon?: string;
28+
public icon: string | null = null;
2729

2830
@property({ reflect: true })
29-
size: PFSize = PFSize.Medium;
30-
31-
static styles: CSSResult[] = [
32-
PFFAIcons,
33-
PFAvatar,
34-
css`
35-
:host {
36-
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
37-
38-
display: flex;
39-
place-content: center;
40-
}
41-
:host([size="pf-m-lg"]) {
42-
--icon-height: 4rem;
43-
--icon-border: 0.25rem;
44-
}
45-
:host([size="pf-m-md"]) {
46-
--icon-height: 2rem;
47-
--icon-border: 0.125rem;
48-
}
49-
:host([size="pf-m-sm"]) {
50-
--icon-height: 1rem;
51-
--icon-border: 0.125rem;
52-
}
53-
:host([size="pf-m-xl"]) {
54-
--icon-height: 6rem;
55-
--icon-border: 0.25rem;
56-
}
57-
.pf-c-avatar {
58-
--pf-c-avatar--BorderRadius: 0;
59-
--pf-c-avatar--Height: calc(
60-
var(--icon-height) + var(--icon-border) + var(--icon-border)
61-
);
62-
--pf-c-avatar--Width: calc(
63-
var(--icon-height) + var(--icon-border) + var(--icon-border)
64-
);
65-
}
66-
.icon {
67-
--app-icon-shadow-blend-color: color-mix(
68-
in srgb,
69-
var(--app-icon--shadow-background-color, var(--pf-global--BackgroundColor--150))
70-
100%,
71-
black 100%
72-
);
73-
74-
font-size: var(--icon-font-size, var(--icon-height));
75-
color: var(--ak-global--Color--100);
76-
padding: var(--icon-border);
77-
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
78-
line-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
79-
filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color));
80-
}
81-
82-
div {
83-
height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
84-
}
85-
`,
86-
];
31+
public size: PFSize = PFSize.Medium;
8732

8833
render(): TemplateResult {
89-
// prettier-ignore
90-
return match([this.name, this.icon])
91-
.with([P.nullish, P.nullish],
92-
() => html`<div><i part="icon" aria-hidden="true" class="icon fas fa-question-circle"></i></div>`)
93-
.with([P._, P.string.startsWith("fa://")],
94-
([_name, icon]) => html`<div><i part="icon" aria-hidden="true" class="icon fas ${icon.replaceAll("fa://", "")}"></i></div>`)
95-
.with([P._, P.string],
96-
([_name, icon]) => html`<img part="icon" aria-hidden="true" class="icon pf-c-avatar" src="${icon}" alt="${msg("Application Icon")}" />`)
97-
.with([P.string, P.nullish],
98-
([name]) => html`<span part="icon" aria-hidden="true" class="icon">${name.charAt(0).toUpperCase()}</span>`)
99-
.exhaustive();
34+
const applicationName = this.name ?? msg("Application");
35+
const label = msg(str`${applicationName} Icon`);
36+
37+
if (this.icon?.startsWith(AppIcon.FontAwesomeProtocol)) {
38+
return html`<i
39+
part="icon font-awesome"
40+
role="img"
41+
aria-label=${label}
42+
class="icon fas ${this.icon.slice(AppIcon.FontAwesomeProtocol.length)}"
43+
></i>`;
44+
}
45+
46+
const insignia = this.name?.charAt(0).toUpperCase() ?? "�";
47+
48+
if (this.icon) {
49+
return html`<img
50+
part="icon image"
51+
role="img"
52+
aria-label=${label}
53+
class="icon"
54+
src=${this.icon}
55+
alt=${insignia}
56+
/>`;
57+
}
58+
59+
return html`<span part="icon insignia" role="img" aria-label=${label} class="icon"
60+
>${insignia}</span
61+
>`;
10062
}
10163
}
10264

web/src/elements/ak-locale-context/ak-locale-context.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST } from "#common/constants";
88
import { AKElement } from "#elements/Base";
99
import { customEvent } from "#elements/utils/customEvents";
1010

11-
import { html } from "lit";
1211
import { customElement, property } from "lit/decorators.js";
1312

1413
/**
@@ -26,6 +25,10 @@ import { customElement, property } from "lit/decorators.js";
2625
*/
2726
@customElement("ak-locale-context")
2827
export class LocaleContext extends WithBrandConfig(AKElement) {
28+
protected createRenderRoot(): HTMLElement | DocumentFragment {
29+
return this;
30+
}
31+
2932
/// @attribute The text representation of the current locale */
3033
@property({ attribute: true, type: String })
3134
locale = DEFAULT_LOCALE;
@@ -90,10 +93,6 @@ export class LocaleContext extends WithBrandConfig(AKElement) {
9093
// works just fine for almost every use case.
9194
this.dispatchEvent(customEvent(EVENT_LOCALE_CHANGE));
9295
}
93-
94-
render() {
95-
return html`<slot></slot>`;
96-
}
9796
}
9897

9998
export default LocaleContext;

web/src/elements/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export type LitPropertyKey<K> = K extends string ? `.${K}` | `?${K}` | K : K;
6565
export type LitFC<P> = (
6666
props: P,
6767
children?: null | SlottedTemplateResult,
68-
) => SlottedTemplateResult | SlottedTemplateResult[];
68+
) => SlottedTemplateResult | SlottedTemplateResult[] | null;
6969

7070
//#endregion
7171

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export function isInteractiveElement(target: Element | null | undefined): target is HTMLElement {
2+
if (!target || !(target instanceof HTMLElement)) {
3+
return false;
4+
}
5+
6+
if (target.hasAttribute("disabled") || target.inert) {
7+
return false;
8+
}
9+
10+
const { tabIndex } = target;
11+
12+
// Despite our type definitions, this method isn't available in all browsers,
13+
// so we fallback to assuming the element is visible.
14+
const visible = target.checkVisibility?.() ?? true;
15+
16+
return (
17+
visible &&
18+
(tabIndex === 0 ||
19+
tabIndex === -1 ||
20+
target.matches("button, [role='button'], a[href], input, select, textarea"))
21+
);
22+
}

0 commit comments

Comments
 (0)