Skip to content

Commit 23ff5b2

Browse files
committed
🚧 WIP: Explain Code Button UI
1 parent ef90a62 commit 23ff5b2

8 files changed

Lines changed: 714 additions & 0 deletions

File tree

‎ec.config.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import pluginWorkersPlayground from "./src/plugins/expressive-code/workers-playg
88
import pluginOutputFrame from "./src/plugins/expressive-code/output-frame.js";
99
import pluginDefaultTitles from "./src/plugins/expressive-code/default-titles.js";
1010
import pluginGraphqlApiExplorer from "./src/plugins/expressive-code/graphql-api-explorer.js";
11+
import pluginExplainCode from "./src/plugins/expressive-code/explain-code.js";
1112

1213
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections";
1314
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers";
@@ -20,6 +21,7 @@ export default defineEcConfig({
2021
pluginCollapsibleSections(),
2122
pluginGraphqlApiExplorer(),
2223
pluginLineNumbers(),
24+
pluginExplainCode(),
2325
],
2426
defaultProps: {
2527
showLineNumbers: false,

‎src/components/Sheet.astro‎

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
---
2+
interface Props {
3+
/**
4+
* Unique identifier for the sheet (used for aria-labelledby)
5+
*/
6+
id: string;
7+
/**
8+
* Side of the screen where the sheet appears
9+
* @default "right"
10+
*/
11+
side?: "top" | "right" | "bottom" | "left";
12+
/**
13+
* Whether to show the close button
14+
* @default true
15+
*/
16+
showCloseButton?: boolean;
17+
/**
18+
* Custom class for the sheet content
19+
*/
20+
class?: string;
21+
}
22+
23+
const {
24+
id,
25+
side = "right",
26+
showCloseButton = true,
27+
class: className = "",
28+
} = Astro.props;
29+
30+
const sideClasses = {
31+
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
32+
bottom:
33+
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
34+
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
35+
right:
36+
"inset-y-0 right-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
37+
};
38+
---
39+
40+
<div class="sheet-container" data-sheet-id={id}>
41+
<!-- Trigger slot -->
42+
<slot name="trigger" />
43+
44+
<!-- Overlay -->
45+
<div
46+
class="sheet-overlay data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
47+
data-state="closed"
48+
aria-hidden="true"
49+
>
50+
</div>
51+
52+
<!-- Sheet Content -->
53+
<div
54+
class:list={[
55+
"sheet-content data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 bg-[var(--sl-color-bg)] p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
56+
sideClasses[side],
57+
className,
58+
]}
59+
data-state="closed"
60+
role="dialog"
61+
aria-modal="true"
62+
aria-labelledby={`${id}-title`}
63+
aria-describedby={`${id}-description`}
64+
>
65+
{
66+
showCloseButton && (
67+
<button
68+
type="button"
69+
class="sheet-close absolute top-4 right-4 rounded-sm opacity-70 ring-offset-[var(--sl-color-bg)] transition-opacity hover:opacity-100 focus:ring-2 focus:ring-[var(--sl-color-text-accent)] focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"
70+
aria-label="Close"
71+
>
72+
<svg
73+
xmlns="http://www.w3.org/2000/svg"
74+
width="24"
75+
height="24"
76+
viewBox="0 0 24 24"
77+
fill="none"
78+
stroke="currentColor"
79+
stroke-width="2"
80+
stroke-linecap="round"
81+
stroke-linejoin="round"
82+
class="h-4 w-4"
83+
>
84+
<path d="M18 6 6 18" />
85+
<path d="m6 6 12 12" />
86+
</svg>
87+
</button>
88+
)
89+
}
90+
91+
<!-- Content slots -->
92+
<slot />
93+
</div>
94+
</div>
95+
96+
<script>
97+
function initSheet(container: HTMLElement) {
98+
const sheetId = container.dataset.sheetId;
99+
if (!sheetId) return;
100+
101+
const trigger = container.querySelector('[slot="trigger"]');
102+
const overlay = container.querySelector(".sheet-overlay") as HTMLElement;
103+
const content = container.querySelector(".sheet-content") as HTMLElement;
104+
const closeButton = container.querySelector(".sheet-close");
105+
106+
if (!trigger || !overlay || !content) return;
107+
108+
let isOpen = false;
109+
110+
function openSheet() {
111+
isOpen = true;
112+
overlay.dataset.state = "open";
113+
content.dataset.state = "open";
114+
document.body.style.overflow = "hidden";
115+
116+
// Focus management
117+
const firstFocusable = content.querySelector(
118+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
119+
) as HTMLElement;
120+
firstFocusable?.focus();
121+
}
122+
123+
function closeSheet() {
124+
isOpen = false;
125+
overlay.dataset.state = "closed";
126+
content.dataset.state = "closed";
127+
document.body.style.overflow = "";
128+
}
129+
130+
// Trigger click
131+
trigger.addEventListener("click", (e) => {
132+
e.preventDefault();
133+
openSheet();
134+
});
135+
136+
// Close button click
137+
closeButton?.addEventListener("click", closeSheet);
138+
139+
// Overlay click
140+
overlay.addEventListener("click", closeSheet);
141+
142+
// Escape key
143+
document.addEventListener("keydown", (e) => {
144+
if (e.key === "Escape" && isOpen) {
145+
closeSheet();
146+
}
147+
});
148+
149+
// Prevent content clicks from closing
150+
content.addEventListener("click", (e) => {
151+
e.stopPropagation();
152+
});
153+
}
154+
155+
// Initialize all sheets on the page
156+
document.addEventListener("astro:page-load", () => {
157+
document.querySelectorAll(".sheet-container").forEach((container) => {
158+
initSheet(container as HTMLElement);
159+
});
160+
});
161+
</script>

‎src/components/overrides/Head.astro‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,4 +240,5 @@ metaTags.map((attrs) => {
240240
<script src="src/scripts/footnotes.ts"></script>
241241
<script src="src/scripts/mermaid.ts"></script>
242242
<script src="src/scripts/analytics.ts"></script>
243+
<script src="src/scripts/explain-code.ts"></script>
243244
<Default><slot /></Default>
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// @ts-check
2+
import { definePlugin } from "@expressive-code/core";
3+
4+
export default () => {
5+
return definePlugin({
6+
name: "Adds 'Explain Code' button to code blocks with 10+ lines",
7+
baseStyles: `
8+
.explain-button {
9+
position: absolute;
10+
top: 0.5rem;
11+
right: 0.5rem;
12+
z-index: 10;
13+
display: flex;
14+
align-items: center;
15+
justify-content: center;
16+
width: 2rem;
17+
height: 2rem;
18+
padding: 0;
19+
border: 1px solid var(--ec-brdCol);
20+
border-radius: 0.25rem;
21+
background: var(--sl-color-bg);
22+
color: var(--sl-color-text);
23+
cursor: pointer;
24+
transition: all 0.2s;
25+
opacity: 1;
26+
}
27+
28+
.explain-button:hover {
29+
background: var(--sl-color-gray-6);
30+
border-color: var(--sl-color-text-accent);
31+
}
32+
33+
.explain-button:focus {
34+
outline: 2px solid var(--sl-color-text-accent);
35+
outline-offset: 2px;
36+
}
37+
38+
.explain-button svg {
39+
width: 1rem;
40+
height: 1rem;
41+
}
42+
43+
.explain-tooltip {
44+
position: absolute;
45+
top: -2rem;
46+
right: 0;
47+
padding: 0.25rem 0.5rem;
48+
background: var(--sl-color-black);
49+
color: var(--sl-color-white);
50+
font-size: 0.75rem;
51+
border-radius: 0.25rem;
52+
white-space: nowrap;
53+
opacity: 0;
54+
pointer-events: none;
55+
transition: opacity 0.2s;
56+
}
57+
58+
.explain-button:hover .explain-tooltip {
59+
opacity: 1;
60+
}
61+
`,
62+
hooks: {
63+
postprocessRenderedBlock: async (context) => {
64+
const lineCount = context.codeBlock.code.split("\n").length;
65+
66+
if (lineCount < 10) return;
67+
68+
const codeContent = context.codeBlock.code;
69+
const language = context.codeBlock.language;
70+
const sheetId = `explain-code-${Math.random().toString(36).substring(2, 11)}`;
71+
72+
// Find the pre element to add the button to
73+
const findPre = (node) => {
74+
if (node.tagName === "pre") return node;
75+
if (node.children) {
76+
for (const child of node.children) {
77+
const result = findPre(child);
78+
if (result) return result;
79+
}
80+
}
81+
return null;
82+
};
83+
84+
const preElement = findPre(context.renderData.blockAst);
85+
if (!preElement) return;
86+
87+
// Add class to pre element to help position the copy button
88+
preElement.properties = preElement.properties || {};
89+
preElement.properties.className = preElement.properties.className || [];
90+
if (Array.isArray(preElement.properties.className)) {
91+
preElement.properties.className.push("has-explain-button");
92+
}
93+
94+
const explainButton = {
95+
type: "element",
96+
tagName: "button",
97+
properties: {
98+
className: ["explain-button"],
99+
type: "button",
100+
"data-sheet-trigger": sheetId,
101+
"data-code-content": codeContent,
102+
"data-code-language": language,
103+
"aria-label": "Explain Code",
104+
},
105+
children: [
106+
{
107+
type: "element",
108+
tagName: "svg",
109+
properties: {
110+
xmlns: "http://www.w3.org/2000/svg",
111+
width: "24",
112+
height: "24",
113+
viewBox: "0 0 24 24",
114+
fill: "none",
115+
stroke: "currentColor",
116+
"stroke-width": "2",
117+
"stroke-linecap": "round",
118+
"stroke-linejoin": "round",
119+
},
120+
children: [
121+
{
122+
type: "element",
123+
tagName: "path",
124+
properties: {
125+
d: "M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z",
126+
},
127+
children: [],
128+
},
129+
{
130+
type: "element",
131+
tagName: "path",
132+
properties: {
133+
d: "M20 3v4",
134+
},
135+
children: [],
136+
},
137+
{
138+
type: "element",
139+
tagName: "path",
140+
properties: {
141+
d: "M22 5h-4",
142+
},
143+
children: [],
144+
},
145+
{
146+
type: "element",
147+
tagName: "path",
148+
properties: {
149+
d: "M4 17v2",
150+
},
151+
children: [],
152+
},
153+
{
154+
type: "element",
155+
tagName: "path",
156+
properties: {
157+
d: "M5 18H3",
158+
},
159+
children: [],
160+
},
161+
],
162+
},
163+
{
164+
type: "element",
165+
tagName: "span",
166+
properties: {
167+
className: ["explain-tooltip"],
168+
},
169+
children: [{ type: "text", value: "Explain Code" }],
170+
},
171+
],
172+
};
173+
174+
preElement.children.unshift(explainButton);
175+
},
176+
},
177+
});
178+
};

0 commit comments

Comments
 (0)