@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2902 -0
- package/dist/components.d.mts +791 -0
- package/dist/components.mjs +2 -0
- package/dist/config-SI9IyFiC.mjs +184 -0
- package/dist/doc-context-CEqzMYKv.d.mts +568 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +4 -0
- package/dist/ui-GKFD0mx5.mjs +15986 -0
- package/package.json +131 -0
- package/skill/SKILL.md +49 -0
- package/skill/references/components/charts.md +172 -0
- package/skill/references/components/document.md +98 -0
- package/skill/references/components/forms.md +35 -0
- package/skill/references/components/investigation.md +198 -0
- package/skill/references/components/layout.md +68 -0
- package/skill/references/components/output.md +158 -0
- package/skill/references/components/planning.md +155 -0
- package/skill/references/components/reports.md +252 -0
- package/skill/references/components/shadcn.md +19 -0
- package/skill/references/components.md +186 -0
- package/skill/references/extending.md +48 -0
- package/src/ask-sheet.ts +40 -0
- package/src/assets/css.ts +392 -0
- package/src/assets/scripts.ts +78 -0
- package/src/catalog.ts +287 -0
- package/src/cli.ts +178 -0
- package/src/client/doc-events.ts +1108 -0
- package/src/client/entry.ts +27 -0
- package/src/client-js.ts +47 -0
- package/src/component-map.ts +47 -0
- package/src/components/ui/accordion.tsx +77 -0
- package/src/components/ui/alert-dialog.tsx +185 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +208 -0
- package/src/components/ui/avatar.tsx +106 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/breadcrumb.tsx +121 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +88 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +226 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/carousel.tsx +246 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/combobox.tsx +298 -0
- package/src/components/ui/command.tsx +193 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +4 -0
- package/src/components/ui/drawer.tsx +227 -0
- package/src/components/ui/dropdown-menu.tsx +269 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +237 -0
- package/src/components/ui/fieldset.tsx +32 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/hover-card.tsx +50 -0
- package/src/components/ui/input-group.tsx +159 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +202 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +284 -0
- package/src/components/ui/message-scroller.tsx +128 -0
- package/src/components/ui/message.tsx +91 -0
- package/src/components/ui/meter.tsx +80 -0
- package/src/components/ui/native-select.tsx +64 -0
- package/src/components/ui/navigation-menu.tsx +170 -0
- package/src/components/ui/pagination.tsx +133 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/questionnaire.tsx +328 -0
- package/src/components/ui/radio-group.tsx +35 -0
- package/src/components/ui/resizable.tsx +49 -0
- package/src/components/ui/scroll-area.tsx +50 -0
- package/src/components/ui/select.tsx +201 -0
- package/src/components/ui/separator.tsx +22 -0
- package/src/components/ui/sheet.tsx +135 -0
- package/src/components/ui/sidebar.tsx +730 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +51 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +113 -0
- package/src/components/ui/tabs.tsx +82 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toast.tsx +229 -0
- package/src/components/ui/toggle-group.tsx +87 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/components.ts +78 -0
- package/src/config.ts +56 -0
- package/src/define.ts +122 -0
- package/src/doc-context.ts +20 -0
- package/src/editor.ts +94 -0
- package/src/format-error.ts +78 -0
- package/src/guards.ts +84 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/html.ts +91 -0
- package/src/hydrate/export-index.ts +232 -0
- package/src/hydrate/import-scan.ts +169 -0
- package/src/hydrate/plugins.ts +118 -0
- package/src/hydrate/runtime-module.ts +145 -0
- package/src/hydrate-runtime.ts +67 -0
- package/src/hydrate.ts +147 -0
- package/src/index.ts +16 -0
- package/src/init.ts +57 -0
- package/src/langs.ts +148 -0
- package/src/lines.ts +53 -0
- package/src/load-user-module.ts +191 -0
- package/src/mdx.ts +248 -0
- package/src/paths.ts +36 -0
- package/src/rehype/shiki.ts +533 -0
- package/src/remark/alerts.ts +53 -0
- package/src/remark/ast.ts +96 -0
- package/src/remark/callouts.ts +25 -0
- package/src/remark/code-file.ts +85 -0
- package/src/remark/code-meta.ts +21 -0
- package/src/remark/directives.ts +170 -0
- package/src/remark/file-paths.ts +64 -0
- package/src/remark/headings.ts +131 -0
- package/src/remark/no-js.ts +40 -0
- package/src/render.ts +337 -0
- package/src/serve.ts +322 -0
- package/src/styles/globals.css +134 -0
- package/src/styles/shadcn.css +641 -0
- package/src/tailwind.ts +119 -0
- package/src/ui/approvals.tsx +76 -0
- package/src/ui/ask-question.tsx +386 -0
- package/src/ui/ask.tsx +206 -0
- package/src/ui/attrs.ts +51 -0
- package/src/ui/audit.tsx +139 -0
- package/src/ui/bar-chart.tsx +334 -0
- package/src/ui/benchmarks.tsx +143 -0
- package/src/ui/bits.tsx +537 -0
- package/src/ui/board.tsx +173 -0
- package/src/ui/bridge.tsx +207 -0
- package/src/ui/bumps.tsx +178 -0
- package/src/ui/callout.tsx +106 -0
- package/src/ui/changes.tsx +89 -0
- package/src/ui/chart-bits.tsx +52 -0
- package/src/ui/chart.ts +577 -0
- package/src/ui/checks.tsx +203 -0
- package/src/ui/child-index.tsx +44 -0
- package/src/ui/children.ts +43 -0
- package/src/ui/chips.tsx +49 -0
- package/src/ui/cmd.tsx +27 -0
- package/src/ui/columns.tsx +31 -0
- package/src/ui/comments.tsx +544 -0
- package/src/ui/compare.tsx +67 -0
- package/src/ui/decision.tsx +79 -0
- package/src/ui/deps.tsx +78 -0
- package/src/ui/details.tsx +43 -0
- package/src/ui/diff-parse.ts +307 -0
- package/src/ui/diff.tsx +458 -0
- package/src/ui/diffstat.tsx +58 -0
- package/src/ui/due.tsx +65 -0
- package/src/ui/effort.tsx +29 -0
- package/src/ui/endpoints.tsx +118 -0
- package/src/ui/envvars.tsx +96 -0
- package/src/ui/figure.tsx +37 -0
- package/src/ui/file-icon.ts +1020 -0
- package/src/ui/file-link.ts +32 -0
- package/src/ui/file-ref.tsx +39 -0
- package/src/ui/files.tsx +112 -0
- package/src/ui/findings.tsx +85 -0
- package/src/ui/flow.tsx +73 -0
- package/src/ui/funnel.tsx +121 -0
- package/src/ui/gantt.tsx +443 -0
- package/src/ui/gauges.tsx +135 -0
- package/src/ui/glossary.tsx +29 -0
- package/src/ui/graph-layout.ts +149 -0
- package/src/ui/graph-specs.tsx +102 -0
- package/src/ui/graph.tsx +278 -0
- package/src/ui/grid.tsx +119 -0
- package/src/ui/hypothesis.tsx +94 -0
- package/src/ui/icon.tsx +80 -0
- package/src/ui/incident.tsx +130 -0
- package/src/ui/index.ts +342 -0
- package/src/ui/ins-del.tsx +41 -0
- package/src/ui/json.tsx +190 -0
- package/src/ui/layout.ts +9 -0
- package/src/ui/line-chart.tsx +251 -0
- package/src/ui/matrix.tsx +208 -0
- package/src/ui/meta.tsx +73 -0
- package/src/ui/option.tsx +64 -0
- package/src/ui/owner.tsx +40 -0
- package/src/ui/packages.tsx +104 -0
- package/src/ui/pathway.tsx +96 -0
- package/src/ui/phase.tsx +41 -0
- package/src/ui/pie-chart.tsx +170 -0
- package/src/ui/plan.tsx +77 -0
- package/src/ui/pre.tsx +175 -0
- package/src/ui/priority.tsx +51 -0
- package/src/ui/props.tsx +77 -0
- package/src/ui/quadrant.tsx +172 -0
- package/src/ui/radar.tsx +200 -0
- package/src/ui/ref.tsx +160 -0
- package/src/ui/release.tsx +178 -0
- package/src/ui/req.tsx +42 -0
- package/src/ui/review.tsx +133 -0
- package/src/ui/risk.tsx +67 -0
- package/src/ui/sankey.tsx +287 -0
- package/src/ui/scatter.tsx +237 -0
- package/src/ui/schema.tsx +113 -0
- package/src/ui/score.tsx +105 -0
- package/src/ui/search.tsx +120 -0
- package/src/ui/series.tsx +38 -0
- package/src/ui/severity.tsx +69 -0
- package/src/ui/shadcn.tsx +216 -0
- package/src/ui/spark.tsx +86 -0
- package/src/ui/stack.tsx +57 -0
- package/src/ui/stats.tsx +63 -0
- package/src/ui/status-badge.tsx +66 -0
- package/src/ui/statuspage.tsx +238 -0
- package/src/ui/steps.tsx +74 -0
- package/src/ui/summary.tsx +41 -0
- package/src/ui/symbol-ref.tsx +73 -0
- package/src/ui/terminal.tsx +117 -0
- package/src/ui/tests.tsx +218 -0
- package/src/ui/timeline.tsx +63 -0
- package/src/ui/toc.tsx +56 -0
- package/src/ui/tones.ts +187 -0
- package/src/ui/trace.tsx +69 -0
- package/src/ui/tree.tsx +281 -0
- package/src/ui/treemap.tsx +128 -0
- package/src/ui/venn.tsx +258 -0
- package/src/ui/verdict.tsx +78 -0
- package/src/ui/waterfall.tsx +142 -0
package/src/tailwind.ts
ADDED
|
@@ -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("'", "'")
|
|
61
|
+
.replaceAll(""", '"')
|
|
62
|
+
.replaceAll("<", "<")
|
|
63
|
+
.replaceAll(">", ">")
|
|
64
|
+
.replaceAll("&", "&");
|
|
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
|
+
);
|