Skip to content

Commit e3381c3

Browse files
authored
feat(ipa): Add <Example.Reason> explanation component (#59)
1 parent 3aa05ea commit e3381c3

5 files changed

Lines changed: 148 additions & 0 deletions

File tree

ipa/dev/component-fixtures.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,49 @@ name: list-resources
119119

120120
</Example.Incorrect>
121121

122+
## `<Example.Reason>`
123+
124+
`<Example.Reason>` is the short prose explanation inside `<Example.Correct>` and
125+
`<Example.Incorrect>` blocks — why the pattern is correct or incorrect, the
126+
principle rather than a restatement of the guideline. It renders beneath the
127+
code block under the example's tinted hairline, with an inline "Why:" lead-in in
128+
the verdict color.
129+
130+
### Inside a correct example
131+
132+
<Example.Correct>
133+
134+
```yaml
135+
PATCH /clusters/{clusterId}
136+
Content-Type: application/merge-patch+json
137+
```
138+
139+
<Example.Reason>
140+
Merge-patch is simpler for clients — send only the fields to change.
141+
</Example.Reason>
142+
143+
</Example.Correct>
144+
145+
### Inside an incorrect example, long-form
146+
147+
<Example.Incorrect>
148+
149+
```yaml
150+
components:
151+
schemas:
152+
Cluster:
153+
properties:
154+
_id: { type: string }
155+
__v: { type: integer }
156+
```
157+
158+
<Example.Reason>
159+
Exposing `_id` and `__v` leaks MongoDB document internals into the API
160+
surface, coupling clients to the storage layer.
161+
</Example.Reason>
162+
163+
</Example.Incorrect>
164+
122165
## `<Workflow>`
123166

124167
`<Workflow>` renders the manual evaluation steps of a guideline as a numbered
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* The --ex-* tokens are defined on the Example variant root
2+
(Example.module.css) and reach these rules by cascade. */
3+
.reason {
4+
margin-top: 0.75rem;
5+
padding-top: 0.6rem;
6+
border-top: 1px solid var(--ex-border);
7+
font-size: 0.85rem;
8+
line-height: 1.6;
9+
color: var(--ifm-font-color-base);
10+
}
11+
12+
.lead {
13+
font-weight: 700;
14+
color: var(--ex-header-color);
15+
}
16+
17+
.reason code {
18+
font-size: 88%;
19+
}
20+
21+
/* MDX wraps the prose in <p>: the first paragraph flows inline after the
22+
"Why:" lead, subsequent paragraphs break below. */
23+
.reason p {
24+
display: inline;
25+
margin: 0;
26+
}
27+
28+
.reason p + p {
29+
display: block;
30+
margin-top: 0.5rem;
31+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
4+
import { Example } from "./index";
5+
6+
describe("<Example.Reason>", () => {
7+
it("renders the explanation prose", () => {
8+
render(
9+
<Example.Reason>Merge-patch is simpler for clients.</Example.Reason>,
10+
);
11+
12+
expect(
13+
screen.getByText(/merge-patch is simpler for clients/i),
14+
).toBeInTheDocument();
15+
});
16+
17+
it("renders a 'Why:' lead-in", () => {
18+
render(<Example.Reason>Some explanation.</Example.Reason>);
19+
20+
expect(screen.getByText("Why:")).toBeInTheDocument();
21+
});
22+
23+
it("accepts paragraph children without invalid DOM nesting", () => {
24+
// MDX wraps indented multi-line children in <p>, so the component's
25+
// container must not itself be a <p> (React warns via console.error).
26+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
27+
28+
render(
29+
<Example.Reason>
30+
<p>First sentence of the reason.</p>
31+
</Example.Reason>,
32+
);
33+
34+
expect(
35+
screen.getByText(/first sentence of the reason/i),
36+
).toBeInTheDocument();
37+
expect(error).not.toHaveBeenCalled();
38+
39+
error.mockRestore();
40+
});
41+
42+
it("renders inside an example beneath its code block", () => {
43+
render(
44+
<Example.Correct>
45+
<pre>
46+
<code>name: list-resources</code>
47+
</pre>
48+
<Example.Reason>The name field is required.</Example.Reason>
49+
</Example.Correct>,
50+
);
51+
const code = screen.getByText("name: list-resources");
52+
const reason = screen.getByText(/the name field is required/i);
53+
54+
expect(
55+
code.compareDocumentPosition(reason) & Node.DOCUMENT_POSITION_FOLLOWING,
56+
).toBeTruthy();
57+
});
58+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { type ReactElement, type ReactNode } from "react";
2+
import styles from "./Reason.module.css";
3+
4+
interface ReasonProps {
5+
children: ReactNode;
6+
}
7+
8+
export function Reason({ children }: ReasonProps): ReactElement {
9+
return (
10+
<div className={styles.reason}>
11+
<span className={styles.lead}>Why:</span> {children}
12+
</div>
13+
);
14+
}

src/components/ipa/Example/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type ReactElement, type ReactNode } from "react";
22
import { Accordion } from "@site/src/components/ui";
33
import { type ExampleType } from "./types";
4+
import { Reason } from "./Reason";
45
import styles from "./Example.module.css";
56
import clsx from "clsx";
67

@@ -47,4 +48,5 @@ IncorrectExample.displayName = "Incorrect";
4748
export const Example = Object.assign(ExampleBase, {
4849
Correct: CorrectExample,
4950
Incorrect: IncorrectExample,
51+
Reason: Reason,
5052
});

0 commit comments

Comments
 (0)