@cas-smartdesign/focus-ring 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta http-equiv="X-UA-Compatible" content="ie=edge" />
7
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
8
+ <style>
9
+ .markdown-body {
10
+ box-sizing: border-box;
11
+ min-width: 200px;
12
+ max-width: 980px;
13
+ margin: 0 auto !important;
14
+ padding-bottom: 45px;
15
+ }
16
+ </style>
17
+ <script type="module" crossorigin src="./doc.mjs"></script>
18
+ <link rel="stylesheet" crossorigin href="./doc.css">
19
+ </head>
20
+
21
+ <body>
22
+ <div class="markdown-body">
23
+ <div id="markdown-container"></div>
24
+ </div>
25
+ </body>
26
+ </html>
@@ -0,0 +1,63 @@
1
+ declare global {
2
+ interface DocumentEventMap {
3
+ "sd-focus-ring-refresh": Event;
4
+ }
5
+ }
6
+ export interface FocusRingDefaults {
7
+ /** Ring colour used when `--sd-focus-ring-color` is not set on the target. */
8
+ color: string;
9
+ /** Ring thickness in px used when `--sd-focus-ring-width` is not set. */
10
+ width: number;
11
+ /** Gap in px used when `--sd-focus-ring-offset` is not set. */
12
+ offset: number;
13
+ }
14
+ declare class FocusRingController {
15
+ private ring;
16
+ private observedFocusChain;
17
+ private target;
18
+ private targetFullyVisible;
19
+ private frame;
20
+ private installed;
21
+ private shown;
22
+ private needsPromotion;
23
+ private lastPlacement;
24
+ private readonly visibility;
25
+ private readonly hostAttributes;
26
+ /**
27
+ * Shadow roots we listen to directly. focusin/focusout carry a relatedTarget, and when both
28
+ * the element losing and the element gaining focus are in the same shadow root, retargeting
29
+ * makes them equal at the host — so the event never propagates out to the document. Document
30
+ * level listeners therefore miss focus moves *within* a shadow root. Roots are registered
31
+ * lazily: entering a root from outside always reaches the document, and from then on the
32
+ * root's own listeners report the internal moves.
33
+ */
34
+ private readonly observedShadowRoots;
35
+ /** Fallbacks used when a target does not set the corresponding custom property. */
36
+ readonly defaults: FocusRingDefaults;
37
+ constructor();
38
+ /** Installs the document-level listeners and the top-layer ring node. Idempotent. */
39
+ install(): void;
40
+ /** Removes everything `install()` added. */
41
+ uninstall(): void;
42
+ /** Re-evaluates which element should carry the ring. Safe to call at any time. */
43
+ refresh: () => void;
44
+ /**
45
+ * Resolves the focused host and the ring target, and returns whether the host counts as
46
+ * focus-visible. Runs from the render frame rather than from the focus events that schedule
47
+ * it: components with manual detection toggle their `sd-focus-visible` attribute from their
48
+ * own focus listeners, which run *after* the controller's document-level capture listener —
49
+ * resolving synchronously would read the attribute state of the previous focus.
50
+ */
51
+ private resolveTarget;
52
+ /** Registers focus listeners on every shadow root the current focus chain passes through. */
53
+ private observeShadowRootsAlongFocusChain;
54
+ private setTarget;
55
+ private scheduleRender;
56
+ private render;
57
+ private toggle;
58
+ private createRingNode;
59
+ }
60
+ /** The shared focus-ring controller. Call `FocusRing.install()` once at app start. */
61
+ export declare const FocusRing: FocusRingController;
62
+ export default FocusRing;
63
+ //# sourceMappingURL=focus-ring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"focus-ring.d.ts","sourceRoot":"","sources":["../focus-ring.ts"],"names":[],"mappings":"AAAA,OAAO,CAAC,MAAM,CAAC;IACX,UAAU,gBAAgB;QACtB,uBAAuB,EAAE,KAAK,CAAC;KAClC;CACJ;AAED,MAAM,WAAW,iBAAiB;IAC9B,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,MAAM,EAAE,MAAM,CAAC;CAClB;AAqED,cAAM,mBAAmB;IACrB,OAAO,CAAC,IAAI,CAA4B;IACxC,OAAO,CAAC,kBAAkB,CAAiB;IAC3C,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,kBAAkB,CAAQ;IAClC,OAAO,CAAC,KAAK,CAAK;IAClB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,aAAa,CAAM;IAE3B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAuB;IAClD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAmB;IAClD;;;;;;;OAOG;IACH,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAyB;IAE7D,mFAAmF;IACnF,SAAgB,QAAQ,EAAE,iBAAiB,CAIzC;;IAqBF,qFAAqF;IAC9E,OAAO,IAAI,IAAI;IAYtB,4CAA4C;IACrC,SAAS,IAAI,IAAI;IAyBxB,kFAAkF;IAC3E,OAAO,QAAO,IAAI,CAGvB;IAEF;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAkCrB,6FAA6F;IAC7F,OAAO,CAAC,iCAAiC;IAazC,OAAO,CAAC,SAAS;IAiBjB,OAAO,CAAC,cAAc,CAepB;IAEF,OAAO,CAAC,MAAM;IAgDd,OAAO,CAAC,MAAM;IA6Bd,OAAO,CAAC,cAAc;CA4BzB;AAED,sFAAsF;AACtF,eAAO,MAAM,SAAS,qBAA4B,CAAC;AACnD,eAAe,SAAS,CAAC"}
@@ -0,0 +1,150 @@
1
+ //#region focus-ring.ts
2
+ var e = "data-sd-focus-ring", t = "sd-focus-visible";
3
+ /**
4
+ * Returns the chain of elements containing the focus — `document.activeElement` plus the active
5
+ * element of every open shadow root it hosts — innermost (the deep active element) first.
6
+ * Through focus retargeting, every element of this chain "is focused" from its own tree's view.
7
+ */
8
+ function n() {
9
+ let e = [], t = document.activeElement;
10
+ for (; t;) e.unshift(t), t = t.shadowRoot?.activeElement ?? null;
11
+ return e;
12
+ }
13
+ /** Looks up an id within a document or shadow root (ShadowRoot has no getElementById). */
14
+ function r(e, t) {
15
+ return e instanceof Document ? e.getElementById(t) : e.querySelector(`#${CSS.escape(t)}`);
16
+ }
17
+ /** Resolves the `aria-activedescendant` of a composite widget to its element, if any. */
18
+ function i(e) {
19
+ let t = e.getAttribute("aria-activedescendant");
20
+ if (!t) return null;
21
+ let n = e.getRootNode();
22
+ if (n instanceof Document || n instanceof ShadowRoot) {
23
+ let e = r(n, t);
24
+ if (e) return e;
25
+ }
26
+ return document.getElementById(t);
27
+ }
28
+ /**
29
+ * Finds the nearest element carrying the `sd-focus-visible` attribute — a component's manual
30
+ * focus-visible detection, overruling the native heuristic. Walks the flattened tree upwards
31
+ * from the focused element, crossing shadow boundaries.
32
+ */
33
+ function a(e) {
34
+ for (let n = e; n;) {
35
+ if (n instanceof HTMLElement && n.hasAttribute(t)) return n;
36
+ let e = n.getRootNode();
37
+ n = n.parentElement ?? (e instanceof ShadowRoot ? e.host : null);
38
+ }
39
+ return null;
40
+ }
41
+ function o(e, t, n) {
42
+ let r = Number.parseFloat(e.getPropertyValue(t));
43
+ return Number.isFinite(r) ? r : n;
44
+ }
45
+ function s(e, t, n) {
46
+ return e.getPropertyValue(t).trim() || n;
47
+ }
48
+ /** The shared focus-ring controller. Call `FocusRing.install()` once at app start. */
49
+ var c = new class {
50
+ constructor() {
51
+ this.ring = null, this.observedFocusChain = [], this.target = null, this.targetFullyVisible = !0, this.frame = 0, this.installed = !1, this.shown = !1, this.needsPromotion = !1, this.lastPlacement = "", this.observedShadowRoots = /* @__PURE__ */ new Set(), this.defaults = {
52
+ color: "var(--color-border-interaction-focus, #111)",
53
+ width: 1,
54
+ offset: 2
55
+ }, this.refresh = () => {
56
+ this.observeShadowRootsAlongFocusChain(), this.scheduleRender();
57
+ }, this.scheduleRender = () => {
58
+ this.frame ||= requestAnimationFrame(() => {
59
+ this.frame = 0, this.render(), this.shown && this.scheduleRender();
60
+ });
61
+ }, this.visibility = new IntersectionObserver((e) => {
62
+ let t = e[e.length - 1];
63
+ t && (this.targetFullyVisible = t.isIntersecting && t.intersectionRatio >= .99, this.scheduleRender());
64
+ }, { threshold: [
65
+ 0,
66
+ .99,
67
+ 1
68
+ ] }), this.hostAttributes = new MutationObserver(this.refresh);
69
+ }
70
+ /** Installs the document-level listeners and the top-layer ring node. Idempotent. */
71
+ install() {
72
+ this.installed || typeof document > "u" || (this.installed = !0, this.ring = this.createRingNode(), document.addEventListener("focusin", this.refresh, !0), document.addEventListener("focusout", this.refresh, !0), document.addEventListener("sd-focus-ring-refresh", this.refresh), this.refresh());
73
+ }
74
+ /** Removes everything `install()` added. */
75
+ uninstall() {
76
+ this.installed && (this.installed = !1, document.removeEventListener("focusin", this.refresh, !0), document.removeEventListener("focusout", this.refresh, !0), document.removeEventListener("sd-focus-ring-refresh", this.refresh), this.observedShadowRoots.forEach((e) => {
77
+ e.removeEventListener("focusin", this.refresh, !0), e.removeEventListener("focusout", this.refresh, !0);
78
+ }), this.observedShadowRoots.clear(), this.visibility.disconnect(), this.hostAttributes.disconnect(), this.frame &&= (cancelAnimationFrame(this.frame), 0), this.setTarget(null), this.observedFocusChain = [], this.ring?.remove(), this.ring = null);
79
+ }
80
+ /**
81
+ * Resolves the focused host and the ring target, and returns whether the host counts as
82
+ * focus-visible. Runs from the render frame rather than from the focus events that schedule
83
+ * it: components with manual detection toggle their `sd-focus-visible` attribute from their
84
+ * own focus listeners, which run *after* the controller's document-level capture listener —
85
+ * resolving synchronously would read the attribute state of the previous focus.
86
+ */
87
+ resolveTarget() {
88
+ let e = n(), r = e[0] ?? null, o = a(r), s = o ?? (r instanceof HTMLElement && r.matches(":focus-visible") ? r : null), c = o ? o.getAttribute(t) !== "false" : s != null, l = null;
89
+ for (let t of e) if (l = i(t), l) break;
90
+ let u = s ? l ?? s : null, d = this.observedFocusChain;
91
+ return (e.length !== d.length || e.some((e, t) => e !== d[t])) && (this.hostAttributes.disconnect(), e.forEach((e) => this.hostAttributes.observe(e, {
92
+ attributes: !0,
93
+ attributeFilter: ["aria-activedescendant", t]
94
+ })), this.observedFocusChain = e), this.setTarget(u), c;
95
+ }
96
+ /** Registers focus listeners on every shadow root the current focus chain passes through. */
97
+ observeShadowRootsAlongFocusChain() {
98
+ let e = document.activeElement;
99
+ for (; e?.shadowRoot?.activeElement;) {
100
+ let t = e.shadowRoot;
101
+ this.observedShadowRoots.has(t) || (this.observedShadowRoots.add(t), t.addEventListener("focusin", this.refresh, !0), t.addEventListener("focusout", this.refresh, !0)), e = t.activeElement;
102
+ }
103
+ }
104
+ setTarget(e) {
105
+ e !== this.target && (this.target && this.visibility.unobserve(this.target), this.target = e, this.targetFullyVisible = !0, this.needsPromotion = !0, e && this.visibility.observe(e));
106
+ }
107
+ render() {
108
+ let e = this.ring, t = this.resolveTarget(), n = this.target;
109
+ if (!e || !n || !t || !this.targetFullyVisible || !n.isConnected) {
110
+ this.toggle(!1);
111
+ return;
112
+ }
113
+ let r = n.getBoundingClientRect();
114
+ if (r.width === 0 && r.height === 0) {
115
+ this.toggle(!1);
116
+ return;
117
+ }
118
+ let i = getComputedStyle(n), a = o(i, "--sd-focus-ring-width", this.defaults.width), c = o(i, "--sd-focus-ring-offset", this.defaults.offset), l = s(i, "--sd-focus-ring-color", this.defaults.color), u = s(i, "--sd-focus-ring-offset-color", ""), d = s(i, "--sd-focus-ring-radius", i.borderRadius || "0"), f = `${r.top}|${r.left}|${r.width}|${r.height}|${a}|${c}|${l}|${u}|${d}`;
119
+ f !== this.lastPlacement && (this.lastPlacement = f, e.style.top = `${r.top}px`, e.style.left = `${r.left}px`, e.style.width = `${r.width}px`, e.style.height = `${r.height}px`, e.style.borderRadius = d, u ? (e.style.outline = "none", e.style.boxShadow = `0 0 0 ${c}px ${u}, 0 0 0 ${c + a}px ${l}`) : (e.style.boxShadow = "none", e.style.outline = `${a}px solid ${l}`, e.style.outlineOffset = `${c}px`)), this.toggle(!0);
120
+ }
121
+ toggle(e) {
122
+ let t = this.ring;
123
+ if (!t) return;
124
+ typeof t.togglePopover == "function" ? e && (this.needsPromotion || !this.shown) ? (t.matches(":popover-open") && t.hidePopover(), t.showPopover(), this.needsPromotion = !1) : t.togglePopover(e) : t.style.display = e ? "block" : "none";
125
+ let n = this.shown;
126
+ this.shown = e, e && !n && this.scheduleRender();
127
+ }
128
+ createRingNode() {
129
+ let t = document.createElement("div");
130
+ t.setAttribute(e, ""), t.setAttribute("aria-hidden", "true");
131
+ let n = "popover" in HTMLElement.prototype;
132
+ return n && t.setAttribute("popover", "manual"), Object.assign(t.style, {
133
+ position: "fixed",
134
+ margin: "0",
135
+ padding: "0",
136
+ border: "0",
137
+ background: "none",
138
+ inset: "auto",
139
+ width: "0",
140
+ height: "0",
141
+ overflow: "visible",
142
+ pointerEvents: "none",
143
+ zIndex: "2147483647"
144
+ }), n || (t.style.display = "none"), document.body.appendChild(t), t;
145
+ }
146
+ }();
147
+ //#endregion
148
+ export { c as FocusRing, c as default };
149
+
150
+ //# sourceMappingURL=focus-ring.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"focus-ring.mjs","names":[],"sources":["../focus-ring.ts"],"sourcesContent":["declare global {\n interface DocumentEventMap {\n \"sd-focus-ring-refresh\": Event;\n }\n}\n\nexport interface FocusRingDefaults {\n /** Ring colour used when `--sd-focus-ring-color` is not set on the target. */\n color: string;\n /** Ring thickness in px used when `--sd-focus-ring-width` is not set. */\n width: number;\n /** Gap in px used when `--sd-focus-ring-offset` is not set. */\n offset: number;\n}\n\nconst RING_MARKER = \"data-sd-focus-ring\";\nconst MANUAL_FOCUS_VISIBLE = \"sd-focus-visible\";\n\n/**\n * Returns the chain of elements containing the focus — `document.activeElement` plus the active\n * element of every open shadow root it hosts — innermost (the deep active element) first.\n * Through focus retargeting, every element of this chain \"is focused\" from its own tree's view.\n */\nfunction focusChain(): Element[] {\n const chain: Element[] = [];\n let active: Element | null = document.activeElement;\n while (active) {\n chain.unshift(active);\n active = active.shadowRoot?.activeElement ?? null;\n }\n return chain;\n}\n\n/** Looks up an id within a document or shadow root (ShadowRoot has no getElementById). */\nfunction findById(root: Document | ShadowRoot, id: string): Element | null {\n if (root instanceof Document) {\n return root.getElementById(id);\n }\n return root.querySelector(`#${CSS.escape(id)}`);\n}\n\n/** Resolves the `aria-activedescendant` of a composite widget to its element, if any. */\nfunction resolveActiveDescendant(host: Element): Element | null {\n const id = host.getAttribute(\"aria-activedescendant\");\n if (!id) {\n return null;\n }\n const root = host.getRootNode();\n if (root instanceof Document || root instanceof ShadowRoot) {\n const found = findById(root, id);\n if (found) {\n return found;\n }\n }\n return document.getElementById(id);\n}\n\n/**\n * Finds the nearest element carrying the `sd-focus-visible` attribute — a component's manual\n * focus-visible detection, overruling the native heuristic. Walks the flattened tree upwards\n * from the focused element, crossing shadow boundaries.\n */\nfunction findManualFocusVisible(active: Element | null): HTMLElement | null {\n for (let element: Element | null = active; element; ) {\n if (element instanceof HTMLElement && element.hasAttribute(MANUAL_FOCUS_VISIBLE)) {\n return element;\n }\n const root = element.getRootNode();\n element = element.parentElement ?? (root instanceof ShadowRoot ? root.host : null);\n }\n return null;\n}\n\nfunction readNumber(style: CSSStyleDeclaration, property: string, fallback: number): number {\n const value = Number.parseFloat(style.getPropertyValue(property));\n return Number.isFinite(value) ? value : fallback;\n}\n\nfunction readString(style: CSSStyleDeclaration, property: string, fallback: string): string {\n return style.getPropertyValue(property).trim() || fallback;\n}\n\nclass FocusRingController {\n private ring: HTMLElement | null = null;\n private observedFocusChain: Element[] = [];\n private target: Element | null = null;\n private targetFullyVisible = true;\n private frame = 0;\n private installed = false;\n private shown = false;\n private needsPromotion = false;\n private lastPlacement = \"\";\n\n private readonly visibility: IntersectionObserver;\n private readonly hostAttributes: MutationObserver;\n /**\n * Shadow roots we listen to directly. focusin/focusout carry a relatedTarget, and when both\n * the element losing and the element gaining focus are in the same shadow root, retargeting\n * makes them equal at the host — so the event never propagates out to the document. Document\n * level listeners therefore miss focus moves *within* a shadow root. Roots are registered\n * lazily: entering a root from outside always reaches the document, and from then on the\n * root's own listeners report the internal moves.\n */\n private readonly observedShadowRoots = new Set<ShadowRoot>();\n\n /** Fallbacks used when a target does not set the corresponding custom property. */\n public readonly defaults: FocusRingDefaults = {\n color: \"var(--color-border-interaction-focus, #111)\",\n width: 1,\n offset: 2,\n };\n\n constructor() {\n this.visibility = new IntersectionObserver(\n (entries) => {\n const entry = entries[entries.length - 1];\n if (!entry) {\n return;\n }\n // \"Hide when clipped\": keep the ring only while the target is (essentially) fully\n // in view. IntersectionObserver already accounts for intervening scroll containers.\n this.targetFullyVisible = entry.isIntersecting && entry.intersectionRatio >= 0.99;\n this.scheduleRender();\n },\n // Callbacks fire when crossing a threshold; without the upper ones the observer would\n // stay silent while the target becomes partially clipped.\n { threshold: [0, 0.99, 1] },\n );\n this.hostAttributes = new MutationObserver(this.refresh);\n }\n\n /** Installs the document-level listeners and the top-layer ring node. Idempotent. */\n public install(): void {\n if (this.installed || typeof document === \"undefined\") {\n return;\n }\n this.installed = true;\n this.ring = this.createRingNode();\n document.addEventListener(\"focusin\", this.refresh, true);\n document.addEventListener(\"focusout\", this.refresh, true);\n document.addEventListener(\"sd-focus-ring-refresh\", this.refresh);\n this.refresh();\n }\n\n /** Removes everything `install()` added. */\n public uninstall(): void {\n if (!this.installed) {\n return;\n }\n this.installed = false;\n document.removeEventListener(\"focusin\", this.refresh, true);\n document.removeEventListener(\"focusout\", this.refresh, true);\n document.removeEventListener(\"sd-focus-ring-refresh\", this.refresh);\n this.observedShadowRoots.forEach((root) => {\n root.removeEventListener(\"focusin\", this.refresh, true);\n root.removeEventListener(\"focusout\", this.refresh, true);\n });\n this.observedShadowRoots.clear();\n this.visibility.disconnect();\n this.hostAttributes.disconnect();\n if (this.frame) {\n cancelAnimationFrame(this.frame);\n this.frame = 0;\n }\n this.setTarget(null);\n this.observedFocusChain = [];\n this.ring?.remove();\n this.ring = null;\n }\n\n /** Re-evaluates which element should carry the ring. Safe to call at any time. */\n public refresh = (): void => {\n this.observeShadowRootsAlongFocusChain();\n this.scheduleRender();\n };\n\n /**\n * Resolves the focused host and the ring target, and returns whether the host counts as\n * focus-visible. Runs from the render frame rather than from the focus events that schedule\n * it: components with manual detection toggle their `sd-focus-visible` attribute from their\n * own focus listeners, which run *after* the controller's document-level capture listener —\n * resolving synchronously would read the attribute state of the previous focus.\n */\n private resolveTarget(): boolean {\n const chain = focusChain();\n const active = chain[0] ?? null;\n const manual = findManualFocusVisible(active);\n const host = manual ?? (active instanceof HTMLElement && active.matches(\":focus-visible\") ? active : null);\n const focusVisible = manual ? manual.getAttribute(MANUAL_FOCUS_VISIBLE) !== \"false\" : host != null;\n\n // The \"visually focused item\" may be declared on any element the focus is retargeted\n // through — a composite widget may keep `aria-activedescendant` on its host while the\n // real focus sits in an input inside its shadow DOM. The innermost declaration wins.\n let descendant: Element | null = null;\n for (const element of chain) {\n descendant = resolveActiveDescendant(element);\n if (descendant) {\n break;\n }\n }\n const target = host ? (descendant ?? host) : null;\n\n const observed = this.observedFocusChain;\n if (chain.length !== observed.length || chain.some((element, index) => element !== observed[index])) {\n this.hostAttributes.disconnect();\n chain.forEach((element) =>\n this.hostAttributes.observe(element, {\n attributes: true,\n attributeFilter: [\"aria-activedescendant\", MANUAL_FOCUS_VISIBLE],\n }),\n );\n this.observedFocusChain = chain;\n }\n this.setTarget(target);\n return focusVisible;\n }\n\n /** Registers focus listeners on every shadow root the current focus chain passes through. */\n private observeShadowRootsAlongFocusChain(): void {\n let active: Element | null = document.activeElement;\n while (active?.shadowRoot?.activeElement) {\n const root = active.shadowRoot;\n if (!this.observedShadowRoots.has(root)) {\n this.observedShadowRoots.add(root);\n root.addEventListener(\"focusin\", this.refresh, true);\n root.addEventListener(\"focusout\", this.refresh, true);\n }\n active = root.activeElement;\n }\n }\n\n private setTarget(target: Element | null): void {\n if (target === this.target) {\n return;\n }\n if (this.target) {\n this.visibility.unobserve(this.target);\n }\n this.target = target;\n this.targetFullyVisible = true;\n // Re-promote the ring for the new target: another popover (dialog, flyout, ...) may have\n // been added to the top layer in the meantime and would paint above the ring otherwise.\n this.needsPromotion = true;\n if (target) {\n this.visibility.observe(target);\n }\n }\n\n private scheduleRender = (): void => {\n if (this.frame) {\n return;\n }\n this.frame = requestAnimationFrame(() => {\n this.frame = 0;\n this.render();\n // While the ring is visible, keep following the target every frame. Scroll events of\n // containers inside shadow roots are not composed (they never reach a document-level\n // listener), and items can also move without any scroll event (transforms, layout\n // changes, smooth scrolling) — polling the rect while visible covers all of that.\n if (this.shown) {\n this.scheduleRender();\n }\n });\n };\n\n private render(): void {\n const ring = this.ring;\n const focusVisible = this.resolveTarget();\n const target = this.target;\n if (!ring || !target || !focusVisible || !this.targetFullyVisible || !target.isConnected) {\n this.toggle(false);\n return;\n }\n const rect = target.getBoundingClientRect();\n if (rect.width === 0 && rect.height === 0) {\n this.toggle(false);\n return;\n }\n const style = getComputedStyle(target);\n const width = readNumber(style, \"--sd-focus-ring-width\", this.defaults.width);\n const offset = readNumber(style, \"--sd-focus-ring-offset\", this.defaults.offset);\n const color = readString(style, \"--sd-focus-ring-color\", this.defaults.color);\n const offsetColor = readString(style, \"--sd-focus-ring-offset-color\", \"\");\n const radius = readString(style, \"--sd-focus-ring-radius\", style.borderRadius || \"0\");\n\n // The follow loop calls render every frame while visible — only touch the styles\n // when the placement actually changed.\n const placement = `${rect.top}|${rect.left}|${rect.width}|${rect.height}|${width}|${offset}|${color}|${offsetColor}|${radius}`;\n if (placement !== this.lastPlacement) {\n this.lastPlacement = placement;\n\n ring.style.top = `${rect.top}px`;\n ring.style.left = `${rect.left}px`;\n ring.style.width = `${rect.width}px`;\n ring.style.height = `${rect.height}px`;\n ring.style.borderRadius = radius;\n\n if (offsetColor) {\n // \"Masked\" ring: solid offset-coloured gap then the ring colour. Useful when the\n // element sits on a busy surface and a transparent gap would look muddy.\n ring.style.outline = \"none\";\n ring.style.boxShadow = `0 0 0 ${offset}px ${offsetColor}, 0 0 0 ${offset + width}px ${color}`;\n } else {\n // Default: transparent gap via outline-offset. Also renders in forced-colors mode,\n // where box-shadow would not.\n ring.style.boxShadow = \"none\";\n ring.style.outline = `${width}px solid ${color}`;\n ring.style.outlineOffset = `${offset}px`;\n }\n }\n this.toggle(true);\n }\n\n private toggle(visible: boolean): void {\n const ring = this.ring;\n if (!ring) {\n return;\n }\n if (typeof ring.togglePopover === \"function\") {\n if (visible && (this.needsPromotion || !this.shown)) {\n // Elements in the top layer paint in promotion order — z-index has no effect\n // there. Re-promoting (hide + show) moves the ring back to the end of the top\n // layer, above popovers/dialogs that were opened after it.\n if (ring.matches(\":popover-open\")) {\n ring.hidePopover();\n }\n ring.showPopover();\n this.needsPromotion = false;\n } else {\n ring.togglePopover(visible);\n }\n } else {\n ring.style.display = visible ? \"block\" : \"none\";\n }\n const wasShown = this.shown;\n this.shown = visible;\n if (visible && !wasShown) {\n // (Re)start the follow loop.\n this.scheduleRender();\n }\n }\n\n private createRingNode(): HTMLElement {\n const ring = document.createElement(\"div\");\n ring.setAttribute(RING_MARKER, \"\");\n ring.setAttribute(\"aria-hidden\", \"true\");\n const supportsPopover = \"popover\" in HTMLElement.prototype;\n if (supportsPopover) {\n // Top layer, so the ring also sits above dialogs and other stacking contexts.\n ring.setAttribute(\"popover\", \"manual\");\n }\n Object.assign(ring.style, {\n position: \"fixed\",\n margin: \"0\",\n padding: \"0\",\n border: \"0\",\n background: \"none\",\n inset: \"auto\",\n width: \"0\",\n height: \"0\",\n overflow: \"visible\",\n pointerEvents: \"none\",\n zIndex: \"2147483647\",\n });\n if (!supportsPopover) {\n ring.style.display = \"none\";\n }\n document.body.appendChild(ring);\n return ring;\n }\n}\n\n/** The shared focus-ring controller. Call `FocusRing.install()` once at app start. */\nexport const FocusRing = new FocusRingController();\nexport default FocusRing;\n"],"mappings":"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"}
@@ -0,0 +1,167 @@
1
+ {
2
+ "@cypress/vite-dev-server@7.3.2": {
3
+ "licenses": "MIT",
4
+ "repository": "https://github.com/cypress-io/cypress",
5
+ "licenseUrl": "https://github.com/cypress-io/cypress/tree/develop/npm/vite-dev-server#readme"
6
+ },
7
+ "@tsdown/css@0.21.7": {
8
+ "licenses": "MIT",
9
+ "repository": "https://github.com/rolldown/tsdown",
10
+ "licenseUrl": "https://unpkg.com/@tsdown/css@0.21.7/LICENSE"
11
+ },
12
+ "@types/node@25.5.2": {
13
+ "licenses": "MIT",
14
+ "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped",
15
+ "licenseUrl": "https://unpkg.com/@types/node@25.5.2/LICENSE"
16
+ },
17
+ "@types/postcss-prefix-selector@1.16.3": {
18
+ "licenses": "MIT",
19
+ "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped",
20
+ "licenseUrl": "https://unpkg.com/@types/postcss-prefix-selector@1.16.3/LICENSE"
21
+ },
22
+ "@types/trusted-types@2.0.7": {
23
+ "licenses": "MIT",
24
+ "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped",
25
+ "licenseUrl": "https://unpkg.com/@types/trusted-types@2.0.7/LICENSE"
26
+ },
27
+ "@types/yargs@17.0.35": {
28
+ "licenses": "MIT",
29
+ "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped",
30
+ "licenseUrl": "https://unpkg.com/@types/yargs@17.0.35/LICENSE"
31
+ },
32
+ "@vitest/coverage-v8@4.1.2": {
33
+ "licenses": "MIT",
34
+ "repository": "https://github.com/vitest-dev/vitest",
35
+ "licenseUrl": "https://unpkg.com/@vitest/coverage-v8@4.1.2/LICENSE"
36
+ },
37
+ "@vitest/ui@4.1.2": {
38
+ "licenses": "MIT",
39
+ "repository": "https://github.com/vitest-dev/vitest",
40
+ "licenseUrl": "https://unpkg.com/@vitest/ui@4.1.2/LICENSE"
41
+ },
42
+ "axe-core@4.11.2": {
43
+ "licenses": "MPL-2.0",
44
+ "repository": "https://github.com/dequelabs/axe-core",
45
+ "licenseUrl": "https://unpkg.com/axe-core@4.11.2/LICENSE"
46
+ },
47
+ "cypress-axe@1.7.0": {
48
+ "licenses": "MIT",
49
+ "repository": "https://github.com/component-driven/cypress-axe",
50
+ "licenseUrl": "https://unpkg.com/cypress-axe@1.7.0/License.md"
51
+ },
52
+ "cypress@15.13.0": {
53
+ "licenses": "MIT",
54
+ "repository": "https://github.com/cypress-io/cypress",
55
+ "licenseUrl": "https://cypress.io"
56
+ },
57
+ "dompurify@3.4.7": {
58
+ "licenses": "(MPL-2.0 OR Apache-2.0)",
59
+ "repository": "https://github.com/cure53/DOMPurify",
60
+ "licenseUrl": "https://unpkg.com/dompurify@3.4.7/LICENSE"
61
+ },
62
+ "github-markdown-css@5.9.0": {
63
+ "licenses": "MIT",
64
+ "repository": "https://github.com/sindresorhus/github-markdown-css",
65
+ "licenseUrl": "https://unpkg.com/github-markdown-css@5.9.0/license"
66
+ },
67
+ "highlight.js@11.11.1": {
68
+ "licenses": "BSD-3-Clause",
69
+ "repository": "https://github.com/highlightjs/highlight.js",
70
+ "licenseUrl": "https://unpkg.com/highlight.js@11.11.1/LICENSE"
71
+ },
72
+ "junit-report-builder@5.1.1": {
73
+ "licenses": "MIT",
74
+ "repository": "https://github.com/davidparsson/junit-report-builder",
75
+ "licenseUrl": "https://unpkg.com/junit-report-builder@5.1.1/LICENSE"
76
+ },
77
+ "lint-staged@16.4.0": {
78
+ "licenses": "MIT",
79
+ "repository": "https://github.com/lint-staged/lint-staged",
80
+ "licenseUrl": "https://unpkg.com/lint-staged@16.4.0/LICENSE"
81
+ },
82
+ "lit-html@3.3.2": {
83
+ "licenses": "BSD-3-Clause",
84
+ "repository": "https://github.com/lit/lit",
85
+ "licenseUrl": "https://unpkg.com/lit-html@3.3.2/LICENSE"
86
+ },
87
+ "lit@3.3.2": {
88
+ "licenses": "BSD-3-Clause",
89
+ "repository": "https://github.com/lit/lit",
90
+ "licenseUrl": "https://unpkg.com/lit@3.3.2/LICENSE"
91
+ },
92
+ "marked@18.0.0": {
93
+ "licenses": "MIT",
94
+ "repository": "https://github.com/markedjs/marked",
95
+ "licenseUrl": "https://unpkg.com/marked@18.0.0/LICENSE.md"
96
+ },
97
+ "oxfmt@0.44.0": {
98
+ "licenses": "MIT",
99
+ "repository": "https://github.com/oxc-project/oxc",
100
+ "licenseUrl": "https://unpkg.com/oxfmt@0.44.0/LICENSE"
101
+ },
102
+ "oxlint-tsgolint@0.20.0": {
103
+ "licenses": "MIT",
104
+ "repository": "https://github.com/oxc-project/tsgolint",
105
+ "licenseUrl": "https://unpkg.com/oxlint-tsgolint@0.20.0/LICENSE"
106
+ },
107
+ "oxlint@1.59.0": {
108
+ "licenses": "MIT",
109
+ "repository": "https://github.com/oxc-project/oxc",
110
+ "licenseUrl": "https://unpkg.com/oxlint@1.59.0/LICENSE"
111
+ },
112
+ "postcss-prefix-selector@2.1.1": {
113
+ "licenses": "MIT",
114
+ "repository": "https://github.com/RadValentin/postcss-prefix-selector",
115
+ "licenseUrl": "https://unpkg.com/postcss-prefix-selector@2.1.1/LICENSE"
116
+ },
117
+ "postcss@8.5.8": {
118
+ "licenses": "MIT",
119
+ "repository": "https://github.com/postcss/postcss",
120
+ "licenseUrl": "https://unpkg.com/postcss@8.5.8/LICENSE"
121
+ },
122
+ "resolve-pkg@3.0.1": {
123
+ "licenses": "MIT",
124
+ "repository": "https://github.com/sindresorhus/resolve-pkg",
125
+ "licenseUrl": "https://unpkg.com/resolve-pkg@3.0.1/license"
126
+ },
127
+ "sass@1.99.0": {
128
+ "licenses": "MIT",
129
+ "repository": "https://github.com/sass/dart-sass",
130
+ "licenseUrl": "https://unpkg.com/sass@1.99.0/LICENSE"
131
+ },
132
+ "style-dictionary@5.4.0": {
133
+ "licenses": "Apache-2.0",
134
+ "repository": "https://github.com/style-dictionary/style-dictionary",
135
+ "licenseUrl": "https://unpkg.com/style-dictionary@5.4.0/LICENSE"
136
+ },
137
+ "tsdown@0.21.7": {
138
+ "licenses": "MIT",
139
+ "repository": "https://github.com/rolldown/tsdown",
140
+ "licenseUrl": "https://unpkg.com/tsdown@0.21.7/LICENSE"
141
+ },
142
+ "turbo@2.9.4": {
143
+ "licenses": "MIT",
144
+ "repository": "https://github.com/vercel/turborepo",
145
+ "licenseUrl": "https://unpkg.com/turbo@2.9.4/LICENSE"
146
+ },
147
+ "typescript@6.0.2": {
148
+ "licenses": "Apache-2.0",
149
+ "repository": "https://github.com/microsoft/TypeScript",
150
+ "licenseUrl": "https://unpkg.com/typescript@6.0.2/LICENSE.txt"
151
+ },
152
+ "vite@8.0.5": {
153
+ "licenses": "MIT",
154
+ "repository": "https://github.com/vitejs/vite",
155
+ "licenseUrl": "https://unpkg.com/vite@8.0.5/LICENSE.md"
156
+ },
157
+ "vitest@4.1.2": {
158
+ "licenses": "MIT",
159
+ "repository": "https://github.com/vitest-dev/vitest",
160
+ "licenseUrl": "https://unpkg.com/vitest@4.1.2/LICENSE.md"
161
+ },
162
+ "yargs@18.0.0": {
163
+ "licenses": "MIT",
164
+ "repository": "https://github.com/yargs/yargs",
165
+ "licenseUrl": "https://unpkg.com/yargs@18.0.0/LICENSE"
166
+ }
167
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@cas-smartdesign/focus-ring",
3
+ "version": "1.0.0",
4
+ "description": "Shared focus-ring controller that draws a consistent, non-clipping focus ring for SmartDesign elements",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "files": [
7
+ "dist",
8
+ "npm-third-party-licenses.json"
9
+ ],
10
+ "type": "module",
11
+ "main": "dist/focus-ring.mjs",
12
+ "module": "dist/focus-ring.mjs",
13
+ "types": "dist/focus-ring.d.ts",
14
+ "publishConfig": {
15
+ "access": "public",
16
+ "registry": "https://registry.npmjs.org/"
17
+ },
18
+ "devDependencies": {
19
+ "@cas-smartdesign/button": "^7.1.8",
20
+ "@cas-smartdesign/license-generator": "^1.10.0",
21
+ "@cas-smartdesign/design-tokens": "^3.0.3",
22
+ "@cas-smartdesign/element-preview": "^1.1.0",
23
+ "@cas-smartdesign/list-item": "^9.0.2"
24
+ },
25
+ "scripts": {
26
+ "version": "pnpm version",
27
+ "generate-declaration": "tsc -p tsconfig.types.json",
28
+ "build:no-license": "vite build && vite build --mode documentation && pnpm generate-declaration",
29
+ "build": "pnpm generate-license && pnpm build:no-license",
30
+ "dev": "vite",
31
+ "generate-license": "sd-license-generator --r ../../"
32
+ }
33
+ }
package/readme.md ADDED
@@ -0,0 +1,94 @@
1
+ # @cas-smartdesign/focus-ring
2
+
3
+ A shared, framework-agnostic focus-ring controller for SmartDesign elements.
4
+
5
+ It draws **one** focus ring node in the top layer and positions it over whichever element is
6
+ currently focus-visible. Because the ring lives outside every scroll container, focusable
7
+ elements do **not** need to reserve space (padding) for a ring that would otherwise be clipped
8
+ by an ancestor with `overflow: hidden/auto/clip`.
9
+
10
+ The ring is the SmartDesign focus ring: a 1px outline drawn 2px away from the element.
11
+
12
+ ## Usage
13
+
14
+ Install the controller once, at application start:
15
+
16
+ ```ts
17
+ import { FocusRing } from "@cas-smartdesign/focus-ring";
18
+
19
+ FocusRing.install();
20
+ ```
21
+
22
+ After that there is nothing to wire per element. The controller tracks focus automatically and
23
+ shows a ring for the focus-visible element:
24
+
25
+ - **Simple controls** (buttons, links, inputs) — the ring is drawn around the focused element.
26
+ - **Composite widgets** (listboxes, grids, `sd-virtual-list`) — the ring is drawn around the
27
+ element referenced by the widget's `aria-activedescendant`, which is the standard ARIA signal
28
+ for "the visually focused item". The attribute is honoured on the focused element itself or on
29
+ any shadow host the focus is retargeted through (a composite widget may keep it on its host
30
+ while the real focus sits in an input inside its shadow DOM, like `sd-token-selector` does);
31
+ the innermost declaration wins.
32
+
33
+ Only elements matching `:focus-visible` get a ring, so it appears for keyboard focus and not for
34
+ mouse interaction. While the target is clipped by a scroll container, the ring is hidden.
35
+
36
+ ## Manual focus-visible detection (`sd-focus-visible`)
37
+
38
+ The native `:focus-visible` heuristic considers text inputs focus-visible on **every** focus,
39
+ including plain mouse clicks. Components that deliberately implement their own detection can
40
+ overrule the heuristic by setting the `sd-focus-visible` attribute on themselves while they
41
+ contain focus:
42
+
43
+ - `sd-focus-visible=""`
44
+ - Draw the ring around this element, regardless of the heuristic.
45
+ - `sd-focus-visible="false"`
46
+ - Draw no ring while focus is inside this element, even if the browser considers the focused
47
+ element focus-visible.
48
+
49
+ The element nearest to the focused element that carries the attribute (walking the flattened
50
+ tree upwards, crossing shadow boundaries) wins over the heuristic. Remove the attribute on
51
+ `focusout` to hand control back to the heuristic.
52
+
53
+ ## API
54
+
55
+ - `FocusRing.install()` — set up listeners and the ring node (idempotent).
56
+ - `FocusRing.uninstall()` — tear everything down.
57
+ - `FocusRing.refresh()` — re-evaluate the focused target immediately.
58
+ - `FocusRing.defaults` — `{ color, width, offset }` fallbacks used when a target does not set the
59
+ corresponding custom property.
60
+
61
+ Composite widgets that move focus **without** a DOM focus event (e.g. by changing
62
+ `aria-activedescendant` programmatically) are picked up automatically via a `MutationObserver`.
63
+ Anything more exotic can force a re-sync by dispatching a `sd-focus-ring-refresh` event on
64
+ `document`.
65
+
66
+ ## Styling
67
+
68
+ The ring reads these custom properties from the **target** element. They inherit, so they can be
69
+ set once high up the tree or per component:
70
+
71
+ - `--sd-focus-ring-color`
72
+ - Ring colour (default: the `--color-border-interaction-focus` token, `#111`)
73
+ - `--sd-focus-ring-width`
74
+ - Ring thickness in px (default: `1`)
75
+ - `--sd-focus-ring-offset`
76
+ - Gap between the element and the ring in px (default: `2`)
77
+ - `--sd-focus-ring-radius`
78
+ - Ring corner radius (default: the element's own `border-radius`)
79
+ - `--sd-focus-ring-offset-color`
80
+ - When set, the offset gap is filled with this colour (a "masked" ring drawn with box-shadow)
81
+ instead of being transparent. Useful on busy surfaces. Note: box-shadow is not painted in
82
+ forced-colors mode, so leave this unset when a forced-colors-safe ring is required.
83
+
84
+ ## Custom events
85
+
86
+ - `sd-focus-ring-refresh`
87
+ - Dispatch on `document` to force the controller to re-evaluate the focused target.
88
+
89
+ ## Why not CSS anchor positioning?
90
+
91
+ It cannot be clipped to a container (an anchor-positioned element can only be anchored to a
92
+ descendant of its containing block, and `clip-path` cannot read `anchor()`). Once the ring
93
+ has to react to scrolling in JavaScript anyway, `getBoundingClientRect` positioning is both
94
+ simpler and cross-browser, so this package uses that.