@suzumiyaaoba/mdxr 0.1.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.
Files changed (236) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/dist/cli.d.mts +1 -0
  4. package/dist/cli.mjs +2902 -0
  5. package/dist/components.d.mts +791 -0
  6. package/dist/components.mjs +2 -0
  7. package/dist/config-SI9IyFiC.mjs +184 -0
  8. package/dist/doc-context-CEqzMYKv.d.mts +568 -0
  9. package/dist/index.d.mts +20 -0
  10. package/dist/index.mjs +4 -0
  11. package/dist/ui-GKFD0mx5.mjs +15986 -0
  12. package/package.json +131 -0
  13. package/skill/SKILL.md +49 -0
  14. package/skill/references/components/charts.md +172 -0
  15. package/skill/references/components/document.md +98 -0
  16. package/skill/references/components/forms.md +35 -0
  17. package/skill/references/components/investigation.md +198 -0
  18. package/skill/references/components/layout.md +68 -0
  19. package/skill/references/components/output.md +158 -0
  20. package/skill/references/components/planning.md +155 -0
  21. package/skill/references/components/reports.md +252 -0
  22. package/skill/references/components/shadcn.md +19 -0
  23. package/skill/references/components.md +186 -0
  24. package/skill/references/extending.md +48 -0
  25. package/src/ask-sheet.ts +40 -0
  26. package/src/assets/css.ts +392 -0
  27. package/src/assets/scripts.ts +78 -0
  28. package/src/catalog.ts +287 -0
  29. package/src/cli.ts +178 -0
  30. package/src/client/doc-events.ts +1108 -0
  31. package/src/client/entry.ts +27 -0
  32. package/src/client-js.ts +47 -0
  33. package/src/component-map.ts +47 -0
  34. package/src/components/ui/accordion.tsx +77 -0
  35. package/src/components/ui/alert-dialog.tsx +185 -0
  36. package/src/components/ui/alert.tsx +76 -0
  37. package/src/components/ui/aspect-ratio.tsx +22 -0
  38. package/src/components/ui/attachment.tsx +208 -0
  39. package/src/components/ui/avatar.tsx +106 -0
  40. package/src/components/ui/badge.tsx +52 -0
  41. package/src/components/ui/breadcrumb.tsx +121 -0
  42. package/src/components/ui/bubble.tsx +128 -0
  43. package/src/components/ui/button-group.tsx +88 -0
  44. package/src/components/ui/button.tsx +58 -0
  45. package/src/components/ui/calendar.tsx +226 -0
  46. package/src/components/ui/card.tsx +102 -0
  47. package/src/components/ui/carousel.tsx +246 -0
  48. package/src/components/ui/chart.tsx +379 -0
  49. package/src/components/ui/checkbox.tsx +27 -0
  50. package/src/components/ui/collapsible.tsx +19 -0
  51. package/src/components/ui/combobox.tsx +298 -0
  52. package/src/components/ui/command.tsx +193 -0
  53. package/src/components/ui/context-menu.tsx +271 -0
  54. package/src/components/ui/dialog.tsx +159 -0
  55. package/src/components/ui/direction.tsx +4 -0
  56. package/src/components/ui/drawer.tsx +227 -0
  57. package/src/components/ui/dropdown-menu.tsx +269 -0
  58. package/src/components/ui/empty.tsx +104 -0
  59. package/src/components/ui/field.tsx +237 -0
  60. package/src/components/ui/fieldset.tsx +32 -0
  61. package/src/components/ui/frame.tsx +87 -0
  62. package/src/components/ui/hover-card.tsx +50 -0
  63. package/src/components/ui/input-group.tsx +159 -0
  64. package/src/components/ui/input-otp.tsx +83 -0
  65. package/src/components/ui/input.tsx +19 -0
  66. package/src/components/ui/item.tsx +202 -0
  67. package/src/components/ui/kbd.tsx +26 -0
  68. package/src/components/ui/label.tsx +19 -0
  69. package/src/components/ui/marker.tsx +71 -0
  70. package/src/components/ui/menubar.tsx +284 -0
  71. package/src/components/ui/message-scroller.tsx +128 -0
  72. package/src/components/ui/message.tsx +91 -0
  73. package/src/components/ui/meter.tsx +80 -0
  74. package/src/components/ui/native-select.tsx +64 -0
  75. package/src/components/ui/navigation-menu.tsx +170 -0
  76. package/src/components/ui/pagination.tsx +133 -0
  77. package/src/components/ui/popover.tsx +87 -0
  78. package/src/components/ui/progress.tsx +82 -0
  79. package/src/components/ui/questionnaire.tsx +328 -0
  80. package/src/components/ui/radio-group.tsx +35 -0
  81. package/src/components/ui/resizable.tsx +49 -0
  82. package/src/components/ui/scroll-area.tsx +50 -0
  83. package/src/components/ui/select.tsx +201 -0
  84. package/src/components/ui/separator.tsx +22 -0
  85. package/src/components/ui/sheet.tsx +135 -0
  86. package/src/components/ui/sidebar.tsx +730 -0
  87. package/src/components/ui/skeleton.tsx +13 -0
  88. package/src/components/ui/slider.tsx +51 -0
  89. package/src/components/ui/spinner.tsx +16 -0
  90. package/src/components/ui/switch.tsx +31 -0
  91. package/src/components/ui/table.tsx +113 -0
  92. package/src/components/ui/tabs.tsx +82 -0
  93. package/src/components/ui/textarea.tsx +17 -0
  94. package/src/components/ui/toast.tsx +229 -0
  95. package/src/components/ui/toggle-group.tsx +87 -0
  96. package/src/components/ui/toggle.tsx +43 -0
  97. package/src/components/ui/tooltip.tsx +65 -0
  98. package/src/components.ts +78 -0
  99. package/src/config.ts +56 -0
  100. package/src/define.ts +122 -0
  101. package/src/doc-context.ts +20 -0
  102. package/src/editor.ts +94 -0
  103. package/src/format-error.ts +78 -0
  104. package/src/guards.ts +84 -0
  105. package/src/hooks/use-mobile.ts +21 -0
  106. package/src/html.ts +91 -0
  107. package/src/hydrate/export-index.ts +232 -0
  108. package/src/hydrate/import-scan.ts +169 -0
  109. package/src/hydrate/plugins.ts +118 -0
  110. package/src/hydrate/runtime-module.ts +145 -0
  111. package/src/hydrate-runtime.ts +67 -0
  112. package/src/hydrate.ts +147 -0
  113. package/src/index.ts +16 -0
  114. package/src/init.ts +57 -0
  115. package/src/langs.ts +148 -0
  116. package/src/lines.ts +53 -0
  117. package/src/load-user-module.ts +191 -0
  118. package/src/mdx.ts +248 -0
  119. package/src/paths.ts +36 -0
  120. package/src/rehype/shiki.ts +533 -0
  121. package/src/remark/alerts.ts +53 -0
  122. package/src/remark/ast.ts +96 -0
  123. package/src/remark/callouts.ts +25 -0
  124. package/src/remark/code-file.ts +85 -0
  125. package/src/remark/code-meta.ts +21 -0
  126. package/src/remark/directives.ts +170 -0
  127. package/src/remark/file-paths.ts +64 -0
  128. package/src/remark/headings.ts +131 -0
  129. package/src/remark/no-js.ts +40 -0
  130. package/src/render.ts +337 -0
  131. package/src/serve.ts +322 -0
  132. package/src/styles/globals.css +134 -0
  133. package/src/styles/shadcn.css +641 -0
  134. package/src/tailwind.ts +119 -0
  135. package/src/ui/approvals.tsx +76 -0
  136. package/src/ui/ask-question.tsx +386 -0
  137. package/src/ui/ask.tsx +206 -0
  138. package/src/ui/attrs.ts +51 -0
  139. package/src/ui/audit.tsx +139 -0
  140. package/src/ui/bar-chart.tsx +334 -0
  141. package/src/ui/benchmarks.tsx +143 -0
  142. package/src/ui/bits.tsx +537 -0
  143. package/src/ui/board.tsx +173 -0
  144. package/src/ui/bridge.tsx +207 -0
  145. package/src/ui/bumps.tsx +178 -0
  146. package/src/ui/callout.tsx +106 -0
  147. package/src/ui/changes.tsx +89 -0
  148. package/src/ui/chart-bits.tsx +52 -0
  149. package/src/ui/chart.ts +577 -0
  150. package/src/ui/checks.tsx +203 -0
  151. package/src/ui/child-index.tsx +44 -0
  152. package/src/ui/children.ts +43 -0
  153. package/src/ui/chips.tsx +49 -0
  154. package/src/ui/cmd.tsx +27 -0
  155. package/src/ui/columns.tsx +31 -0
  156. package/src/ui/comments.tsx +544 -0
  157. package/src/ui/compare.tsx +67 -0
  158. package/src/ui/decision.tsx +79 -0
  159. package/src/ui/deps.tsx +78 -0
  160. package/src/ui/details.tsx +43 -0
  161. package/src/ui/diff-parse.ts +307 -0
  162. package/src/ui/diff.tsx +458 -0
  163. package/src/ui/diffstat.tsx +58 -0
  164. package/src/ui/due.tsx +65 -0
  165. package/src/ui/effort.tsx +29 -0
  166. package/src/ui/endpoints.tsx +118 -0
  167. package/src/ui/envvars.tsx +96 -0
  168. package/src/ui/figure.tsx +37 -0
  169. package/src/ui/file-icon.ts +1020 -0
  170. package/src/ui/file-link.ts +32 -0
  171. package/src/ui/file-ref.tsx +39 -0
  172. package/src/ui/files.tsx +112 -0
  173. package/src/ui/findings.tsx +85 -0
  174. package/src/ui/flow.tsx +73 -0
  175. package/src/ui/funnel.tsx +121 -0
  176. package/src/ui/gantt.tsx +443 -0
  177. package/src/ui/gauges.tsx +135 -0
  178. package/src/ui/glossary.tsx +29 -0
  179. package/src/ui/graph-layout.ts +149 -0
  180. package/src/ui/graph-specs.tsx +102 -0
  181. package/src/ui/graph.tsx +278 -0
  182. package/src/ui/grid.tsx +119 -0
  183. package/src/ui/hypothesis.tsx +94 -0
  184. package/src/ui/icon.tsx +80 -0
  185. package/src/ui/incident.tsx +130 -0
  186. package/src/ui/index.ts +342 -0
  187. package/src/ui/ins-del.tsx +41 -0
  188. package/src/ui/json.tsx +190 -0
  189. package/src/ui/layout.ts +9 -0
  190. package/src/ui/line-chart.tsx +251 -0
  191. package/src/ui/matrix.tsx +208 -0
  192. package/src/ui/meta.tsx +73 -0
  193. package/src/ui/option.tsx +64 -0
  194. package/src/ui/owner.tsx +40 -0
  195. package/src/ui/packages.tsx +104 -0
  196. package/src/ui/pathway.tsx +96 -0
  197. package/src/ui/phase.tsx +41 -0
  198. package/src/ui/pie-chart.tsx +170 -0
  199. package/src/ui/plan.tsx +77 -0
  200. package/src/ui/pre.tsx +175 -0
  201. package/src/ui/priority.tsx +51 -0
  202. package/src/ui/props.tsx +77 -0
  203. package/src/ui/quadrant.tsx +172 -0
  204. package/src/ui/radar.tsx +200 -0
  205. package/src/ui/ref.tsx +160 -0
  206. package/src/ui/release.tsx +178 -0
  207. package/src/ui/req.tsx +42 -0
  208. package/src/ui/review.tsx +133 -0
  209. package/src/ui/risk.tsx +67 -0
  210. package/src/ui/sankey.tsx +287 -0
  211. package/src/ui/scatter.tsx +237 -0
  212. package/src/ui/schema.tsx +113 -0
  213. package/src/ui/score.tsx +105 -0
  214. package/src/ui/search.tsx +120 -0
  215. package/src/ui/series.tsx +38 -0
  216. package/src/ui/severity.tsx +69 -0
  217. package/src/ui/shadcn.tsx +216 -0
  218. package/src/ui/spark.tsx +86 -0
  219. package/src/ui/stack.tsx +57 -0
  220. package/src/ui/stats.tsx +63 -0
  221. package/src/ui/status-badge.tsx +66 -0
  222. package/src/ui/statuspage.tsx +238 -0
  223. package/src/ui/steps.tsx +74 -0
  224. package/src/ui/summary.tsx +41 -0
  225. package/src/ui/symbol-ref.tsx +73 -0
  226. package/src/ui/terminal.tsx +117 -0
  227. package/src/ui/tests.tsx +218 -0
  228. package/src/ui/timeline.tsx +63 -0
  229. package/src/ui/toc.tsx +56 -0
  230. package/src/ui/tones.ts +187 -0
  231. package/src/ui/trace.tsx +69 -0
  232. package/src/ui/tree.tsx +281 -0
  233. package/src/ui/treemap.tsx +128 -0
  234. package/src/ui/venn.tsx +258 -0
  235. package/src/ui/verdict.tsx +78 -0
  236. package/src/ui/waterfall.tsx +142 -0
@@ -0,0 +1,119 @@
1
+ import { access } from "node:fs/promises";
2
+ import module from "node:module";
3
+ import path from "node:path";
4
+
5
+ import { compile, optimize } from "@tailwindcss/node";
6
+ import { Scanner } from "@tailwindcss/oxide";
7
+
8
+ import { BASE_CSS } from "./assets/css.js";
9
+ import { pkgRoot } from "./paths.js";
10
+
11
+ const require = module.createRequire(import.meta.url);
12
+ const tailwindDir = path.dirname(require.resolve("tailwindcss/package.json"));
13
+
14
+ /** Resolve `id` against the document dir then this package; false if absent. */
15
+ const tryResolve = async (
16
+ id: string,
17
+ base: string
18
+ ): Promise<string | false> => {
19
+ try {
20
+ const resolved = require.resolve(id, { paths: [base, pkgRoot] });
21
+ await access(resolved);
22
+ return resolved;
23
+ } catch {
24
+ return false;
25
+ }
26
+ };
27
+
28
+ const resolveCss = async (
29
+ id: string,
30
+ base: string
31
+ ): Promise<string | false> => {
32
+ // Absolute ids (the theme file is @import'ed by path) resolve to themselves.
33
+ if (path.isAbsolute(id)) {
34
+ return id;
35
+ }
36
+ // Tailwind v4 ships its CSS layers as files inside the package.
37
+ if (id === "tailwindcss") {
38
+ return path.join(tailwindDir, "index.css");
39
+ }
40
+ if (id.startsWith("tailwindcss/")) {
41
+ return path.join(tailwindDir, id.slice("tailwindcss/".length));
42
+ }
43
+ if (id.startsWith(".")) {
44
+ return path.resolve(base, id);
45
+ }
46
+ return await tryResolve(id, base);
47
+ };
48
+
49
+ export interface CssSource {
50
+ content: string;
51
+ extension: string;
52
+ }
53
+
54
+ /**
55
+ * React escapes `&` `'` `"` `<` `>` inside rendered attributes. The scanner
56
+ * must see the literal class text, so decode the entities React emits.
57
+ */
58
+ const decodeEntities = (html: string): string =>
59
+ html
60
+ .replaceAll("&#x27;", "'")
61
+ .replaceAll("&quot;", '"')
62
+ .replaceAll("&lt;", "<")
63
+ .replaceAll("&gt;", ">")
64
+ .replaceAll("&amp;", "&");
65
+
66
+ /** `@import "<path>"` — quoted, with Windows separators normalized. */
67
+ const importLine = (absPath: string): string =>
68
+ `@import ${JSON.stringify(absPath.replaceAll("\\", "/"))};`;
69
+
70
+ /**
71
+ * Compile Tailwind v4 CSS covering every class candidate found in `sources`
72
+ * (the rendered HTML, the .mdx source, bundled user components, our own dist).
73
+ * `themePath` is imported by path — not inlined — so `@import "./x.css"`
74
+ * inside a theme file resolves relative to the theme, and the file lands in
75
+ * `dependencies` (which `mdxr serve` turns into watch targets).
76
+ * Returns minified CSS ready to inline into the HTML document.
77
+ */
78
+ export const buildCss = async (
79
+ sources: CssSource[],
80
+ themePath?: string
81
+ ): Promise<{ css: string; dependencies: string[] }> => {
82
+ const input = [
83
+ // globals.css holds the shadcn/Base UI theme tokens (@theme, :root/.dark
84
+ // vars, custom variants) shared with Storybook; it imports tailwind itself.
85
+ '@import "./src/styles/globals.css";',
86
+ '@plugin "@tailwindcss/typography";',
87
+ // Iconify icons as CSS classes: `icon-[lucide--check]` (dynamic, any
88
+ // installed @iconify-json/* set works).
89
+ '@plugin "@iconify/tailwind4";',
90
+ BASE_CSS,
91
+ themePath === undefined ? "" : importLine(themePath),
92
+ ].join("\n");
93
+
94
+ // `onDependency` is optional in the types but invoked unconditionally
95
+ // inside @tailwindcss/node — and it tells us which files mdxr serve
96
+ // should watch.
97
+ const dependencies = new Set<string>();
98
+ const compiler = await compile(input, {
99
+ base: pkgRoot,
100
+ customCssResolver: resolveCss,
101
+ customJsResolver: tryResolve,
102
+ onDependency: (file) => {
103
+ dependencies.add(file);
104
+ },
105
+ });
106
+
107
+ const candidates = new Scanner({}).scanFiles(
108
+ sources.map((s) => ({
109
+ content: s.extension === "html" ? decodeEntities(s.content) : s.content,
110
+ extension: s.extension,
111
+ }))
112
+ );
113
+
114
+ const css = compiler.build(candidates);
115
+ return {
116
+ css: optimize(css, { minify: true }).code,
117
+ dependencies: [...dependencies],
118
+ };
119
+ };
@@ -0,0 +1,76 @@
1
+ import * as v from "valibot";
2
+
3
+ import { defineComponent } from "../define.js";
4
+ import { nonEmpty } from "../guards.js";
5
+ import { ListPanel, ListRow, Pill, RowNote } from "./bits.js";
6
+ import { Owner } from "./owner.js";
7
+ import { LOC_CLS, TONE } from "./tones.js";
8
+
9
+ export const APPROVAL_STATUSES = [
10
+ "approved",
11
+ "changes-requested",
12
+ "pending",
13
+ "rejected",
14
+ ] as const;
15
+ export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number];
16
+
17
+ const STYLES: Record<
18
+ ApprovalStatus,
19
+ { cls: string; icon: string; label: string }
20
+ > = {
21
+ approved: {
22
+ cls: TONE.emerald,
23
+ icon: "lucide:check",
24
+ label: "Approved",
25
+ },
26
+ "changes-requested": {
27
+ cls: TONE.amber,
28
+ icon: "lucide:message-square-warning",
29
+ label: "Changes requested",
30
+ },
31
+ pending: {
32
+ cls: TONE.neutral,
33
+ icon: "lucide:clock",
34
+ label: "Pending",
35
+ },
36
+ rejected: {
37
+ cls: TONE.red,
38
+ icon: "lucide:x",
39
+ label: "Rejected",
40
+ },
41
+ };
42
+
43
+ export const Approvals = defineComponent(
44
+ {
45
+ description: "承認/レビュー一覧のコンテナ。<Approval> を並べる",
46
+ },
47
+ ({ children }) => <ListPanel as="div">{children}</ListPanel>
48
+ );
49
+
50
+ export const Approval = defineComponent(
51
+ {
52
+ description:
53
+ "承認者1行。name は必須。status は pending|approved|rejected|changes-requested",
54
+ schema: v.looseObject({
55
+ date: v.optional(v.string()),
56
+ name: v.string(),
57
+ role: v.optional(v.string()),
58
+ status: v.optional(v.picklist(APPROVAL_STATUSES), "pending"),
59
+ }),
60
+ },
61
+ ({ name, role, status, date, children }) => {
62
+ const s = STYLES[status];
63
+ return (
64
+ <ListRow align="center">
65
+ <Owner name={name} role={role} />
66
+ <RowNote>{children}</RowNote>
67
+ <span className="ml-auto flex items-baseline gap-2">
68
+ {nonEmpty(date) ? <time className={LOC_CLS}>{date}</time> : null}
69
+ <Pill className={s.cls} icon={s.icon}>
70
+ {s.label}
71
+ </Pill>
72
+ </span>
73
+ </ListRow>
74
+ );
75
+ }
76
+ );
@@ -0,0 +1,386 @@
1
+ import {
2
+ createContext,
3
+ isValidElement,
4
+ useContext,
5
+ useId,
6
+ useMemo,
7
+ } from "react";
8
+ import type { ReactElement, ReactNode } from "react";
9
+ import * as v from "valibot";
10
+
11
+ import type { DocProps } from "../define.js";
12
+ import { defineComponent, flattenChildren } from "../define.js";
13
+ import { nonEmpty } from "../guards.js";
14
+ import { attrTrue, BOOLISH_PROP } from "./attrs.js";
15
+ import { isEl, propOf } from "./children.js";
16
+ import { Icon } from "./icon.js";
17
+ import { CHIP_BORDER_CLS, TEXT, TONE_TEXT } from "./tones.js";
18
+
19
+ /**
20
+ * `<Ask>` question internals: the `Choice`/`Question` components plus the
21
+ * field chrome (ChoiceField/ToggleField/FieldFrame/SelectControl/
22
+ * FieldControl) each question type renders through. All native controls —
23
+ * every type stays interactive in static HTML.
24
+ */
25
+
26
+ export const QUESTION_TYPES = [
27
+ "choice",
28
+ "multi",
29
+ "select",
30
+ "text",
31
+ "textarea",
32
+ "toggle",
33
+ ] as const;
34
+ export type QuestionType = (typeof QUESTION_TYPES)[number];
35
+
36
+ type ChoiceKind = "checkbox" | "option" | "radio";
37
+
38
+ /** The control each question type renders <Choice> children as. */
39
+ const CHOICE_MODE: Record<QuestionType, ChoiceKind> = {
40
+ choice: "radio",
41
+ multi: "checkbox",
42
+ select: "option",
43
+ text: "radio",
44
+ textarea: "radio",
45
+ toggle: "radio",
46
+ };
47
+
48
+ /** MDX attributes arrive as strings; accept `x`, `x="true"`, `x="false"`. */
49
+ const truthy = attrTrue;
50
+
51
+ /** How a <Choice> renders, provided by the enclosing <Question>. */
52
+ const ChoiceMode = createContext<{ mode: ChoiceKind; name: string }>({
53
+ mode: "radio",
54
+ name: "",
55
+ });
56
+
57
+ const CONTROL_CLS = `w-full min-w-0 rounded-lg border ${CHIP_BORDER_CLS} bg-transparent px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-neutral-400 focus-visible:border-neutral-400 focus-visible:ring-3 focus-visible:ring-neutral-200/70 dark:placeholder:text-neutral-500 dark:focus-visible:ring-neutral-800`;
58
+
59
+ /**
60
+ * A single option inside a `<Question type="choice|multi|select">`. Renders
61
+ * as a radio card, a checkbox card, or an `<option>` depending on the
62
+ * question's type — all native controls, so they work in static HTML.
63
+ */
64
+ export const Choice = defineComponent(
65
+ {
66
+ description:
67
+ "<Question> の選択肢。choice=ラジオ / multi=チェックボックス / select=option として描画。checked で初期選択",
68
+ schema: v.looseObject({
69
+ checked: BOOLISH_PROP,
70
+ description: v.optional(v.string()),
71
+ value: v.string(),
72
+ }),
73
+ },
74
+ ({ value, checked, description, children }) => {
75
+ const { mode, name } = useContext(ChoiceMode);
76
+ if (mode === "option") {
77
+ // `checked` is read by the parent <select defaultValue> — React
78
+ // rejects `selected` on <option>.
79
+ return <option value={value}>{children}</option>;
80
+ }
81
+ return (
82
+ <label
83
+ className={`mdxr-choice flex cursor-pointer items-start gap-2.5 rounded-lg border ${CHIP_BORDER_CLS} px-3 py-2 text-sm transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/60`}
84
+ >
85
+ <input
86
+ className="sr-only"
87
+ defaultChecked={truthy(checked)}
88
+ name={name}
89
+ type={mode}
90
+ value={value}
91
+ />
92
+ <span
93
+ aria-hidden
94
+ className={`mdxr-mark ${mode === "radio" ? "mdxr-mark-radio" : "mdxr-mark-box"}`}
95
+ >
96
+ {mode === "radio" ? null : (
97
+ <Icon className="h-3 w-3" name="lucide:check" />
98
+ )}
99
+ </span>
100
+ <span className="mdxr-choice-text min-w-0 flex-1 leading-snug">
101
+ {children}
102
+ {nonEmpty(description) ? (
103
+ <span
104
+ className={`mdxr-choice-desc mt-0.5 block text-xs ${TEXT.muted}`}
105
+ >
106
+ {description}
107
+ </span>
108
+ ) : null}
109
+ </span>
110
+ </label>
111
+ );
112
+ }
113
+ );
114
+
115
+ const isCheckedChoice = (node: ReactNode): node is ReactElement<DocProps> =>
116
+ isValidElement<DocProps>(node) && truthy(propOf(node, "checked"));
117
+
118
+ /** Label/description/data-attrs chrome shared by every question frame. */
119
+ interface QuestionChrome {
120
+ descEl: ReactNode;
121
+ id: string;
122
+ labelEl: ReactNode;
123
+ qAttrs: {
124
+ readonly "data-mdxr-q": "";
125
+ readonly "data-q-label": string;
126
+ readonly "data-q-type": QuestionType;
127
+ };
128
+ }
129
+
130
+ interface ChoiceCtx {
131
+ mode: ChoiceKind;
132
+ name: string;
133
+ }
134
+
135
+ // choice/multi: each <Choice> renders its own card under a shared <fieldset>.
136
+ const ChoiceField = ({
137
+ children,
138
+ chrome,
139
+ ctx,
140
+ }: {
141
+ children?: ReactNode;
142
+ chrome: QuestionChrome;
143
+ ctx: ChoiceCtx;
144
+ }): ReactElement => (
145
+ // <legend> sits at the fieldset's top edge regardless of the
146
+ // fieldset's padding-top, so the top padding lives on the legend.
147
+ <fieldset className="mdxr-q m-0 px-4 pb-3.5" {...chrome.qAttrs}>
148
+ <legend className="p-0 pt-4.5 text-sm font-medium">{chrome.labelEl}</legend>
149
+ {chrome.descEl}
150
+ <div className="mt-2 space-y-1.5">
151
+ <ChoiceMode.Provider value={ctx}>{children}</ChoiceMode.Provider>
152
+ </div>
153
+ </fieldset>
154
+ );
155
+
156
+ const ToggleField = ({
157
+ checked,
158
+ chrome,
159
+ name,
160
+ }: {
161
+ checked: boolean | string | undefined;
162
+ chrome: QuestionChrome;
163
+ name: string;
164
+ }): ReactElement => (
165
+ <div className="mdxr-q px-4 py-3.5" {...chrome.qAttrs}>
166
+ <label
167
+ className="flex cursor-pointer items-center justify-between gap-3"
168
+ htmlFor={chrome.id}
169
+ >
170
+ <span className="text-sm font-medium">
171
+ {chrome.labelEl}
172
+ {chrome.descEl}
173
+ </span>
174
+ <input
175
+ className="sr-only"
176
+ defaultChecked={truthy(checked)}
177
+ id={chrome.id}
178
+ name={name}
179
+ type="checkbox"
180
+ value="yes"
181
+ />
182
+ <span aria-hidden className="mdxr-switch" />
183
+ </label>
184
+ </div>
185
+ );
186
+
187
+ /** label + description + control wrapper used by the text-like types. */
188
+ const FieldFrame = ({
189
+ children,
190
+ chrome,
191
+ }: {
192
+ children?: ReactNode;
193
+ chrome: QuestionChrome;
194
+ }): ReactElement => (
195
+ <div className="mdxr-q px-4 py-3.5" {...chrome.qAttrs}>
196
+ <label className="text-sm font-medium" htmlFor={chrome.id}>
197
+ {chrome.labelEl}
198
+ </label>
199
+ {chrome.descEl}
200
+ <div className="mt-2">{children}</div>
201
+ </div>
202
+ );
203
+
204
+ const SelectControl = ({
205
+ children,
206
+ ctx,
207
+ id,
208
+ name,
209
+ placeholder,
210
+ }: {
211
+ children?: ReactNode;
212
+ ctx: ChoiceCtx;
213
+ id: string;
214
+ name: string;
215
+ placeholder: string | undefined;
216
+ }): ReactElement => {
217
+ const sel = flattenChildren(children).find(isCheckedChoice);
218
+ const selValue = nonEmpty(sel?.props.value) ? sel.props.value : undefined;
219
+ return (
220
+ // No defaultValue when nothing is checked: the browser picks the first
221
+ // option on both SSR and hydration, so the two agree. (With a
222
+ // placeholder option present it is first, so it gets picked — same as
223
+ // the intended "unanswered" display.)
224
+ <select className={CONTROL_CLS} defaultValue={selValue} id={id} name={name}>
225
+ {nonEmpty(placeholder) ? (
226
+ <option disabled value="">
227
+ {placeholder}
228
+ </option>
229
+ ) : null}
230
+ <ChoiceMode.Provider value={ctx}>{children}</ChoiceMode.Provider>
231
+ </select>
232
+ );
233
+ };
234
+
235
+ const FieldControl = ({
236
+ children,
237
+ ctx,
238
+ id,
239
+ name,
240
+ placeholder,
241
+ rows,
242
+ t,
243
+ value,
244
+ }: {
245
+ children?: ReactNode;
246
+ ctx: ChoiceCtx;
247
+ id: string;
248
+ name: string;
249
+ placeholder: string | undefined;
250
+ rows: string | undefined;
251
+ t: QuestionType;
252
+ value: string | undefined;
253
+ }): ReactElement => {
254
+ if (t === "textarea") {
255
+ return (
256
+ <textarea
257
+ className={CONTROL_CLS}
258
+ defaultValue={nonEmpty(value) ? value : undefined}
259
+ id={id}
260
+ name={name}
261
+ placeholder={nonEmpty(placeholder) ? placeholder : undefined}
262
+ rows={Math.max(1, Number(rows) || 3)}
263
+ />
264
+ );
265
+ }
266
+ if (t === "select") {
267
+ return (
268
+ <SelectControl ctx={ctx} id={id} name={name} placeholder={placeholder}>
269
+ {children}
270
+ </SelectControl>
271
+ );
272
+ }
273
+ return (
274
+ <input
275
+ className={CONTROL_CLS}
276
+ defaultValue={nonEmpty(value) ? value : undefined}
277
+ id={id}
278
+ name={name}
279
+ placeholder={nonEmpty(placeholder) ? placeholder : undefined}
280
+ type="text"
281
+ />
282
+ );
283
+ };
284
+
285
+ /**
286
+ * One question inside `<Ask>`. `name` is the answer key; the Markdown answer
287
+ * sheet shows `label` (falling back to `name`). Types: `choice` (radio
288
+ * cards), `multi` (checkbox cards), `select` (dropdown), `text`, `textarea`,
289
+ * `toggle` (switch). Defaults to `choice` when it has <Choice> children,
290
+ * otherwise `text`.
291
+ */
292
+ export const Question = defineComponent(
293
+ {
294
+ description:
295
+ "<Ask> 内の質問。name は回答キー。type は choice|multi|select|text|textarea|toggle (省略時は <Choice> があれば choice、なければ text)",
296
+ schema: v.looseObject({
297
+ checked: BOOLISH_PROP,
298
+ description: v.optional(v.string()),
299
+ label: v.optional(v.string()),
300
+ name: v.string(),
301
+ placeholder: v.optional(v.string()),
302
+ required: BOOLISH_PROP,
303
+ rows: v.optional(v.string()),
304
+ type: v.optional(v.picklist(QUESTION_TYPES)),
305
+ value: v.optional(v.string()),
306
+ }),
307
+ },
308
+ ({
309
+ name,
310
+ type,
311
+ label,
312
+ required,
313
+ placeholder,
314
+ value,
315
+ description,
316
+ rows,
317
+ checked,
318
+ children,
319
+ }) => {
320
+ // "Has <Choice> children" — not just any child: a stray text/element
321
+ // child under a text question must not flip it into choice mode.
322
+ const t: QuestionType =
323
+ type ??
324
+ (flattenChildren(children).some((c) => isEl(c, Choice))
325
+ ? "choice"
326
+ : "text");
327
+ // Radio inputs sharing `name` form one group — two questions reusing a
328
+ // name would clobber each other's selection. Suffix a useId so the group
329
+ // is per-question (stable across SSR/hydration) rather than per-key.
330
+ const uid = useId();
331
+ const ctx = useMemo(
332
+ () => ({ mode: CHOICE_MODE[t], name: `${name}${uid}` }),
333
+ [t, name, uid]
334
+ );
335
+ // Resolved label + type ride on the wrapper so the client handler can
336
+ // pair every control with the text the reader saw (Markdown sheet).
337
+ const labelText = nonEmpty(label) ? label : name;
338
+ const chrome: QuestionChrome = {
339
+ descEl: nonEmpty(description) ? (
340
+ <p className={`mt-0.5 text-xs font-normal ${TEXT.muted}`}>
341
+ {description}
342
+ </p>
343
+ ) : null,
344
+ id: `mdxr-q${uid}`,
345
+ labelEl: (
346
+ <>
347
+ {labelText}
348
+ {truthy(required) ? (
349
+ <span className={`ml-0.5 ${TONE_TEXT.red}`}>*</span>
350
+ ) : null}
351
+ </>
352
+ ),
353
+ qAttrs: {
354
+ "data-mdxr-q": "",
355
+ "data-q-label": labelText,
356
+ "data-q-type": t,
357
+ } as const,
358
+ };
359
+
360
+ if (t === "choice" || t === "multi") {
361
+ return (
362
+ <ChoiceField chrome={chrome} ctx={ctx}>
363
+ {children}
364
+ </ChoiceField>
365
+ );
366
+ }
367
+ if (t === "toggle") {
368
+ return <ToggleField checked={checked} chrome={chrome} name={name} />;
369
+ }
370
+ return (
371
+ <FieldFrame chrome={chrome}>
372
+ <FieldControl
373
+ ctx={ctx}
374
+ id={chrome.id}
375
+ name={name}
376
+ placeholder={placeholder}
377
+ rows={rows}
378
+ t={t}
379
+ value={value}
380
+ >
381
+ {children}
382
+ </FieldControl>
383
+ </FieldFrame>
384
+ );
385
+ }
386
+ );