料金表からYES/NO診断を作成|HTML埋め込み
料金表(プラン×機能の○×)をTSV貼り付けすると、プランを選び分けるYES/NOの分岐診断を最短の質問順で自動作成し、LPにHTML埋め込みできる診断フォーム作成ブロックです。診断の冒頭に小さな○×表を置き、質問に使う行といまの問いを同じアンバーで対応づけ、答えるごとに残るプランの列だけが濃く残ります。○×がすべて同じで区別できないプランは大きく警告し、全プランで同じ機能は「質問不要」として灰色で示します。結果はプラン名と「このプランを見る」ボタン。「LPに貼る」で div・style・script を1つにしたHTMLをコピーでき(コピーできない環境ではコードの欄を開いて選択まで案内)、静的HTMLと WordPress のカスタムHTMLブロックで動きます(外部読み込みなし・クラスは yn-)。表は URL に入れて共有でき、架空のホームページ制作・英会話スクール・スポーツジムの見本付き。5プラン×10機能まで。依存ライブラリなし。
プレビュー
BLK-108料金・プラン診断
料金表の○×から質問を組む
最短2問で選べる
3プラン・平均1.7問全プランを区別できます
診断プレビュー
質問 1 / 最大 2
| 料金表 → 質問6行中2行を質問に | ライト | スタンダード | プロ |
|---|---|---|---|
| Q1お知らせの更新機能 | × | ○ | ○ |
| 予約・申込の受付 | × | × | ○ |
Q1お知らせの更新機能は必要ですか?
見本(架空の3業種)
- ホームページ制作3プラン×6機能
- 英会話スクール4プラン×7機能
- スポーツジム5プラン×7機能
1行目はプラン名、2行目からは機能名と○/×。「リンク先」の行は任意です。5プラン×10機能まで、各セル40字まで(リンク先は200字)
読み取った表
| 機能 | ライト | スタンダード | プロ | 質問 |
|---|---|---|---|---|
| スマホ対応 | ○ | ○ | ○ | 質問不要 |
| お知らせの更新機能 | × | ○ | ○ | 使う |
| お問い合わせフォーム | ○ | ○ | ○ | 質問不要 |
| 予約・申込の受付 | × | × | ○ | 使う |
| 英語ページ | × | × | ○ | 無くても選べる |
| 公開後の月次レポート | × | ○ | ○ | 無くても選べる |
説明の全文を読む
料金表(プラン×機能の○×)をTSV貼り付けすると、プランを選び分けるYES/NOの分岐診断を最短の質問順で自動作成し、LPにHTML埋め込みできる診断フォーム作成ブロックです。診断の冒頭に小さな○×表を置き、質問に使う行といまの問いを同じアンバーで対応づけ、答えるごとに残るプランの列だけが濃く残ります。○×がすべて同じで区別できないプランは大きく警告し、全プランで同じ機能は「質問不要」として灰色で示します。結果はプラン名と「このプランを見る」ボタン。「LPに貼る」で div・style・script を1つにしたHTMLをコピーでき(コピーできない環境ではコードの欄を開いて選択まで案内)、静的HTMLと WordPress のカスタムHTMLブロックで動きます(外部読み込みなし・クラスは yn-)。表は URL に入れて共有でき、架空のホームページ制作・英会話スクール・スポーツジムの見本付き。5プラン×10機能まで。依存ライブラリなし。
- 追加日:
- 2026-10-30
- 依存:
- なし
- tags
- #block #pricing #plan-finder #quiz #yes-no-chart #decision-tree #tsv #spreadsheet #embed #wordpress #share-url #tabs #accessibility #keyboard #reduced-motion #hairline #no-dependency
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent,
type ReactNode,
} from "react";
/**
* 料金表の○×から組むプラン診断(最短の質問順)
*
* 差別化: 既存の料金系(pricing-plans・pricing-compare-table・estimate-range-explainer)は読み手に表を見せるだけの見本。
* 本ブロックは表を「入力」として受け取り、選び分けに要る質問の木を組み直して、LPに貼れる診断として書き出す道具にした。
* 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸は使わず、節は hairline 罫線と余白で区切る。
*
* 汎用技法メモ:
* 1. **最短の木は総当たりで求める**。プランは5つまでなので、残っているプランの組(ビットの集合)は最大32通り。
* 組ごとに「全機能で二分した場合」をメモ化して比べ、最悪の質問数→平均の質問数→二分の均等さ→表の上の行の順で選ぶ。
* 貪欲に「いちばん均等に割れる機能」を選ぶだけだと、最悪の質問数が1問増える表がある。
* 2. **全プランで○×が同じ行は質問に使えない**(二分できない)。○×の並びがすべて同じプランは、どの質問でも分けられないので警告する。
* 3. **誤りがあっても診断を消さない**。直前の正しい表を控えておき、誤りは行番号付きで並べる。
* 4. **共有は hash に入れる**。表を UTF-8 → base64url にして #yn= に置く。サーバーに何も送らない。
* 5. **書き出しは div・style・script の1かたまり**。クラスは yn- 接頭辞、利用者の文字は textContent で入れ、JSON の「<」は < に替えて
* script の終わりと誤読されないようにする。リンク先は https:// ・ / ・ # ・ ./ で始まるものだけ通す。
* 6. **切り替えは120msの opacity と transform だけ**。切り替え後は問いの見出しへフォーカスを移し、prefers-reduced-motion では動かさない。
* 答えのボタンは問いが替わっても作り直さず、押した側だけ key を替えて短く光らせる(反応は即時のまま、受け付けたことが見える)。
* 7. **表と問いを同じ色で結ぶ**。診断の冒頭に小さな○×表を置き、いまの問いの行と問いの頭の「Q1」札を同じアンバーにする。
* 答えで外れたプランの列は薄くし、結果ではそのプランの列見出しを塗る。狭い幅では質問に使う行だけを出す。
*/
const MAX_PLANS = 5;
const MAX_FEATURES = 10;
const MAX_CELL = 40;
const MAX_LINK = 200;
const PAGE_URL = "https://designs.first-ch.com/d/yesno-chart-flow";
type Plan = { name: string; link: string };
type Feature = { name: string; marks: boolean[] };
export type PlanTable = { plans: Plan[]; features: Feature[] };
type Parsed = { ok: true; table: PlanTable } | { ok: false; errors: string[] };
type TreeNode = { kind: "q"; f: number; yes: TreeNode; no: TreeNode } | { kind: "r"; plans: number[] };
type Built = { node: TreeNode; max: number; sum: number };
type Tab = "table" | "tree" | "output";
const tsv = (rows: string[][]) => rows.map((r) => r.join("\t")).join("\n");
const SAMPLES: { id: string; label: string; size: string; tsv: string }[] = [
{
id: "web",
label: "ホームページ制作",
size: "3プラン×6機能",
tsv: tsv([
["機能", "ライト", "スタンダード", "プロ"],
["スマホ対応", "○", "○", "○"],
["お知らせの更新機能", "×", "○", "○"],
["お問い合わせフォーム", "○", "○", "○"],
["予約・申込の受付", "×", "×", "○"],
["英語ページ", "×", "×", "○"],
["公開後の月次レポート", "×", "○", "○"],
["リンク先", "#plan-light", "#plan-standard", "#plan-pro"],
]),
},
{
id: "school",
label: "英会話スクール",
size: "4プラン×7機能",
tsv: tsv([
["機能", "月4回", "月8回", "通い放題", "オンライン専用"],
["教室でのレッスン", "○", "○", "○", "×"],
["オンラインレッスン", "×", "×", "○", "○"],
["月8回以上のレッスン", "×", "○", "○", "○"],
["講師の指名", "×", "×", "○", "×"],
["検定対策コース", "×", "○", "○", "×"],
["教材費込み", "○", "○", "○", "○"],
["振替受講", "○", "○", "○", "×"],
["リンク先", "#course-4", "#course-8", "#course-free", "#course-online"],
]),
},
{
id: "gym",
label: "スポーツジム",
size: "5プラン×7機能",
tsv: tsv([
["機能", "平日昼", "夜間", "フルタイム", "家族会員", "法人会員"],
["平日の昼の利用", "○", "×", "○", "○", "○"],
["平日の夜の利用", "×", "○", "○", "○", "○"],
["土日の利用", "×", "×", "○", "○", "○"],
["プールの利用", "×", "×", "○", "○", "○"],
["家族の同伴", "×", "×", "×", "○", "○"],
["月1回のパーソナル指導", "×", "×", "×", "○", "○"],
["専用ロッカー", "×", "×", "○", "○", "○"],
]),
},
];
const DEFAULT_TSV = SAMPLES[0].tsv;
const norm = (v: string) => v.normalize("NFKC").trim().toLowerCase();
const YES = new Set(["○", "〇", "◯", "◎", "o", "✓", "✔", "はい", "yes", "y", "true", "1", "あり", "有"]);
const NO = new Set(["×", "✕", "✖", "✗", "x", "いいえ", "no", "n", "false", "0", "-", "ー", "―", "なし", "無"]);
const LINK_LABELS = new Set(["リンク先", "リンク", "url", "link"]);
const len = (s: string) => [...s].length;
const clip = (s: string, n = 16) => (len(s) > n ? `${[...s].slice(0, n).join("")}…` : s);
function markOf(v: string): boolean | null {
const k = norm(v);
if (YES.has(k)) return true;
if (NO.has(k)) return false;
return null;
}
/** リンク先に使ってよい形か(javascript: などの実行される URL を通さない) */
export const safeLink = (v: string) => /^(https?:\/\/|\/|#|\.{1,2}\/)[^\s"'<>`]*$/i.test(v);
/** スプレッドシートからコピーした TSV(タブが無ければカンマ区切り)を読む。誤りは行番号付きで全部返す */
export function parseTable(src: string): Parsed {
const errors: string[] = [];
const sep = src.includes("\t") ? "\t" : ",";
const rows = src
.replace(/\r\n?/g, "\n")
.split("\n")
.map((l, i) => ({ n: i + 1, cells: l.split(sep).map((c) => c.trim()) }))
.filter((r) => r.cells.some((c) => c !== ""));
if (!rows.length) return { ok: false, errors: ["表が空です。1行目にプラン名、2行目から機能名と○×を入れてください"] };
const [head, ...body] = rows;
const hc = [...head.cells];
while (hc.length > 1 && hc[hc.length - 1] === "") hc.pop();
const names = hc.slice(1);
if (names.length < 2) errors.push(`${head.n}行目: プラン名を2つ以上、タブで区切って並べてください(1列目は空欄か「機能」)`);
if (names.length > MAX_PLANS) errors.push(`${head.n}行目: プランは${MAX_PLANS}つまでです(いま${names.length}つ)`);
names.forEach((nm, j) => {
if (!nm) errors.push(`${head.n}行目: ${j + 2}列目のプラン名が空欄です`);
else if (len(nm) > MAX_CELL) errors.push(`${head.n}行目: プラン名「${clip(nm)}」が${MAX_CELL}字を超えています(${len(nm)}字)`);
else if (names.indexOf(nm) !== j) errors.push(`${head.n}行目: プラン名「${nm}」が2回出てきます`);
});
const links = names.map(() => "");
const features: (Feature & { n: number })[] = [];
let linkLine = 0;
for (const r of body) {
const label = r.cells[0];
const cells = r.cells.slice(1);
while (cells.length > names.length && cells[cells.length - 1] === "") cells.pop();
if (LINK_LABELS.has(norm(label))) {
if (linkLine) {
errors.push(`${r.n}行目: 「リンク先」の行は1つだけにしてください(${linkLine}行目にもあります)`);
continue;
}
linkLine = r.n;
names.forEach((nm, j) => {
const v = cells[j] ?? "";
if (!v) return;
if (len(v) > MAX_LINK) errors.push(`${r.n}行目: 「${nm}」のリンク先が${MAX_LINK}字を超えています`);
else if (!safeLink(v)) errors.push(`${r.n}行目: 「${nm}」のリンク先「${clip(v)}」は使えません。https:// か / か # で始めてください`);
else links[j] = v;
});
continue;
}
if (!label) {
errors.push(`${r.n}行目: 1列目に機能名を入れてください`);
continue;
}
if (len(label) > MAX_CELL) errors.push(`${r.n}行目: 機能名「${clip(label)}」が${MAX_CELL}字を超えています(${len(label)}字)`);
if (features.some((f) => f.name === label)) errors.push(`${r.n}行目: 機能名「${clip(label)}」が2回出てきます`);
if (cells.length > names.length) errors.push(`${r.n}行目: 列が多すぎます(プランは${names.length}つです)`);
const marks: boolean[] = [];
names.forEach((nm, j) => {
const v = cells[j] ?? "";
const m = markOf(v);
if (m === null) {
errors.push(
v === "△"
? `${r.n}行目: 「${clip(nm, 10)}」の列の△は使えません。○か×のどちらかにしてください`
: `${r.n}行目: 「${clip(nm, 10)}」の列が${v ? `「${clip(v, 10)}」` : "空欄"}です。○か×を入れてください`,
);
} else marks.push(m);
});
features.push({ name: label, marks, n: r.n });
}
if (!features.length && names.length >= 2) errors.push(`${head.n + 1}行目から、機能名と○×を1行以上入れてください`);
if (features.length > MAX_FEATURES) {
errors.push(`${features[MAX_FEATURES].n}行目: 機能は${MAX_FEATURES}行までです(いま${features.length}行)`);
}
if (errors.length) return { ok: false, errors };
return {
ok: true,
table: {
plans: names.map((name, j) => ({ name, link: links[j] })),
features: features.map(({ name, marks }) => ({ name, marks })),
},
};
}
const popcount = (m: number) => {
let c = 0;
for (let x = m; x; x &= x - 1) c++;
return c;
};
const bits = (m: number, n: number) => Array.from({ length: n }, (_, j) => j).filter((j) => m & (1 << j));
/** 最悪の質問数→平均の質問数→二分の均等さ→表の上の行、の順で最良の木を選ぶ */
export function buildTree(t: PlanTable): Built {
const n = t.plans.length;
const cols = t.features.map((f) => f.marks.reduce((m, v, j) => (v ? m | (1 << j) : m), 0));
const memo = new Map<number, Built>();
const solve = (mask: number): Built => {
const hit = memo.get(mask);
if (hit) return hit;
let best: (Built & { bal: number }) | null = null;
cols.forEach((c, f) => {
const y = mask & c;
const no = mask & ~c;
if (!y || !no) return;
const a = solve(y);
const b = solve(no);
const cand = {
node: { kind: "q", f, yes: a.node, no: b.node } as TreeNode,
max: 1 + Math.max(a.max, b.max),
sum: a.sum + b.sum + popcount(mask),
bal: Math.min(popcount(y), popcount(no)),
};
if (
!best ||
cand.max < best.max ||
(cand.max === best.max && (cand.sum < best.sum || (cand.sum === best.sum && cand.bal > best.bal)))
) {
best = cand;
}
});
const out: Built = best
? { node: (best as Built).node, max: (best as Built).max, sum: (best as Built).sum }
: { node: { kind: "r", plans: bits(mask, n) }, max: 0, sum: 0 };
memo.set(mask, out);
return out;
};
return solve((1 << n) - 1);
}
export const questionOf = (name: string) => (/[??]$/.test(name) ? name : `${name}は必要ですか?`);
const nodeAt = (root: TreeNode, path: boolean[]) => {
let n = root;
for (const a of path) {
if (n.kind !== "q") break;
n = a ? n.yes : n.no;
}
return n;
};
function walk(node: TreeNode, fn: (n: TreeNode) => void) {
fn(node);
if (node.kind === "q") {
walk(node.yes, fn);
walk(node.no, fn);
}
}
function summarize(t: PlanTable, b: Built) {
const used = new Set<number>();
const groups: number[][] = [];
walk(b.node, (n) => {
if (n.kind === "q") used.add(n.f);
else if (n.plans.length > 1) groups.push(n.plans);
});
const same = t.features.map((f) => f.marks.every((m) => m === f.marks[0]));
return { used, groups, same, avg: b.sum / t.plans.length };
}
/** 診断に使う表。入力が誤りなら、直前に表示していた正しい表(最後の正しい編集→hash→initialTsv→既定の見本)に戻す */
export function pickGoodTsv(o: { source: string; lastGood: string | null; hashTsv: string | null; initialTsv: string }) {
if (parseTable(o.source).ok) return o.source;
if (o.lastGood) return o.lastGood;
if (o.hashTsv && parseTable(o.hashTsv).ok) return o.hashTsv;
if (parseTable(o.initialTsv).ok) return o.initialTsv;
return DEFAULT_TSV;
}
// ---- 共有(#yn= に表を入れる)----
function encodeHash(s: string) {
const bytes = new TextEncoder().encode(s);
let bin = "";
bytes.forEach((c) => (bin += String.fromCharCode(c)));
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function decodeHash(h: string): string | null {
const m = h.match(/^#yn=([A-Za-z0-9_-]{1,8000})$/);
if (!m) return null;
try {
const bin = atob(m[1].replace(/-/g, "+").replace(/_/g, "/"));
return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
} catch {
return null;
}
}
const subscribeHash = (cb: () => void) => {
window.addEventListener("hashchange", cb);
return () => window.removeEventListener("hashchange", cb);
};
const readHash = () => window.location.hash;
const serverHash = () => "";
// ---- 書き出し(div・style・script の1かたまり)----
type Packed = { q: string; y: Packed; n: Packed } | { r: number[] };
const pack = (t: PlanTable, n: TreeNode): Packed =>
n.kind === "q" ? { q: questionOf(t.features[n.f].name), y: pack(t, n.yes), n: pack(t, n.no) } : { r: n.plans };
function hashId(s: string) {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
const EMBED_CSS = `.yn-root{box-sizing:border-box;max-width:560px;margin:0 auto;padding:24px;border:1px solid #e7e5e4;background:#fff;color:#1c1917;font-family:inherit;line-height:1.7;text-align:left}
.yn-root *{box-sizing:border-box}
.yn-root .yn-step{margin:0 0 6px;font-size:13px;color:#57534e;font-variant-numeric:tabular-nums}
.yn-root .yn-lead{margin:0 0 4px;font-size:13px;color:#57534e}
.yn-root .yn-q{margin:0 0 18px;font-size:20px;line-height:1.5;font-weight:700;color:#1c1917;outline:none}
.yn-root .yn-name{display:inline-block;padding-bottom:4px;border-bottom:4px solid #d97706;font-size:28px;line-height:1.3}
.yn-root .yn-q:focus-visible{outline:2px solid #d97706;outline-offset:4px}
.yn-root .yn-ans{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.yn-root .yn-btn{min-height:52px;margin:0;padding:8px 16px;border:1px solid #d6d3d1;border-radius:0;background:#fff;color:#1c1917;font:inherit;font-weight:700;cursor:pointer}
.yn-root .yn-btn:hover{border-color:#d97706;background:#fffbeb}
.yn-root .yn-btn:active{border-color:#d97706;background:#fde68a;transform:translateY(1px)}
.yn-root .yn-go{display:inline-flex;align-items:center;min-height:48px;margin:0 8px 8px 0;padding:8px 20px;background:#b45309;color:#fff;font-weight:700;text-decoration:none}
.yn-root .yn-go:hover{background:#92400e}
.yn-root .yn-foot{display:flex;gap:20px;margin-top:20px;padding-top:8px;border-top:1px solid #e7e5e4}
.yn-root .yn-link{min-height:44px;margin:0;padding:0;border:0;background:none;color:#44403c;font:inherit;font-size:14px;text-decoration:underline;text-underline-offset:3px;cursor:pointer}
.yn-root .yn-link[disabled]{color:#a8a29e;cursor:default;text-decoration:none}
.yn-root .yn-trail{margin:14px 0 0;padding:0;list-style:none;font-size:13px;color:#57534e}
.yn-root .yn-trail li{padding:2px 0 2px 10px;border-left:2px solid #d97706}
.yn-root .yn-btn:focus-visible,.yn-root .yn-go:focus-visible,.yn-root .yn-link:focus-visible{outline:2px solid #d97706;outline-offset:2px}
.yn-root .yn-in{animation:yn-in .12s ease-out}
.yn-root .yn-in .yn-go{animation:yn-cta .6s ease-out .12s both}
@keyframes yn-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:none}}
@keyframes yn-cta{from{box-shadow:0 0 0 0 rgba(217,119,6,.55)}to{box-shadow:0 0 0 10px rgba(217,119,6,0)}}
@media (prefers-reduced-motion:reduce){.yn-root .yn-in,.yn-root .yn-in .yn-go{animation:none}.yn-root .yn-btn:active{transform:none}}`;
// 同じ診断を1ページに2つ貼っても動くように、id ではなく data-yn で探し、まだ動かしていないかたまりだけを動かす
// __DATA__ と __ID__ は1回の置換で埋める(入れた表の文字に __ID__ があっても、それを置き換えない)
const EMBED_JS = `(function(){var D=__DATA__,L=document.getElementsByClassName("yn-root"),k;
for(k=0;k<L.length;k++)if(L[k].getAttribute("data-yn")==="__ID__"&&!L[k].getAttribute("data-yn-on")){L[k].setAttribute("data-yn-on","1");init(L[k])}
function init(r){var path=[],body=r.querySelector(".yn-body"),step=r.querySelector(".yn-step"),back=r.querySelector(".yn-back"),reset=r.querySelector(".yn-reset");
function at(){var n=D.t;for(var i=0;i<path.length&&n.q;i++)n=path[i]?n.y:n.n;return n}
function el(t,c,x){var e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e}
function ok(u){return /^(https?:\\/\\/|\\/|#|\\.{1,2}\\/)[^\\s"'<>\`]*$/i.test(u)}
function show(f){var n=at(),h=el("h3","yn-q");h.tabIndex=-1;body.textContent="";body.className="yn-body";if(f){void body.offsetWidth;body.className="yn-body yn-in"}
if(n.q){step.textContent="質問 "+(path.length+1)+" / 最大 "+D.m;h.textContent=n.q;body.appendChild(h);var a=el("div","yn-ans");
[["はい",true],["いいえ",false]].forEach(function(o){var b=el("button","yn-btn",o[0]);b.type="button";b.onclick=function(){path.push(o[1]);show(1)};a.appendChild(b)});body.appendChild(a)}
else{var ps=n.r.map(function(i){return D.p[i]});step.textContent=path.length+"問で決まりました";
body.appendChild(el("p","yn-lead",ps.length>1?"表の○×では、次のどれかまで絞れました":"おすすめのプラン"));
h.className="yn-q yn-name";h.textContent=ps.map(function(p){return p.n}).join("・");body.appendChild(h);
ps.forEach(function(p){if(p.u&&ok(p.u)){var g=el("a","yn-go",ps.length>1?p.n+"を見る":"このプランを見る");g.href=p.u;body.appendChild(g)}})}
var tr=el("ol","yn-trail"),m=D.t;for(var i=0;i<path.length&&m.q;i++){tr.appendChild(el("li","",m.q+" "+(path[i]?"はい":"いいえ")));m=path[i]?m.y:m.n}
if(path.length)body.appendChild(tr);back.disabled=!path.length;reset.disabled=!path.length;if(f)h.focus()}
back.onclick=function(){if(path.length){path.pop();show(1)}};reset.onclick=function(){path=[];show(1)};
r.addEventListener("keydown",function(e){if(e.key==="ArrowLeft"&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&path.length){e.preventDefault();path.pop();show(1)}});
show(0)}})();`;
/** LP に貼る HTML を作る。外部読み込みなし・クラスは yn- 接頭辞・利用者の文字は textContent で入れる */
export function buildEmbed(t: PlanTable, b: Built) {
const data = {
t: pack(t, b.node),
p: t.plans.map((p) => ({ n: p.name, u: safeLink(p.link) ? p.link : "" })),
m: b.max,
};
const json = JSON.stringify(data).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
const id = `yn-${hashId(json)}`;
return `<div class="yn-root" data-yn="${id}">
<p class="yn-step"></p>
<div class="yn-body"><noscript>この診断は JavaScript を有効にすると使えます。</noscript></div>
<div class="yn-foot"><button type="button" class="yn-link yn-back" disabled>1問戻る</button><button type="button" class="yn-link yn-reset" disabled>最初から</button></div>
</div>
<style>
${EMBED_CSS}
</style>
<script>
${EMBED_JS.replace(/__DATA__|__ID__/g, (m) => (m === "__DATA__" ? json : id))}
</script>`;
}
// ---- 表示 ----
const focusRing =
"outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d97706]";
// 切り替えの演出。どれも opacity と transform(と一度だけの影)で、120〜320ms。prefers-reduced-motion では使わない
const KEYFRAMES = `@keyframes yn-in{from{opacity:0;transform:translateX(8px)}to{opacity:1;transform:none}}
@keyframes yn-fade{from{opacity:0}to{opacity:1}}
@keyframes yn-flash{from{background-color:#fde68a;border-color:#d97706}to{background-color:#fff;border-color:#d6d3d1}}
@keyframes yn-pop{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}
@keyframes yn-bar{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@keyframes yn-cta{from{box-shadow:0 0 0 0 rgba(217,119,6,.55)}to{box-shadow:0 0 0 10px rgba(217,119,6,0)}}`;
const TABS: { id: Tab; label: string }[] = [
{ id: "table", label: "表" },
{ id: "tree", label: "質問の木" },
{ id: "output", label: "出力" },
];
export type YesNoChartFlowProps = {
/** 最初に読む表(TSV)。URL の #yn= があればそちらを優先 */
initialTsv?: string;
headingTag?: "h1" | "h2" | "p";
};
export default function YesNoChartFlow({ initialTsv = DEFAULT_TSV, headingTag }: YesNoChartFlowProps) {
const Heading = headingTag ?? "h2";
const QTag = headingTag === "p" ? "p" : "h3";
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const hash = useSyncExternalStore(subscribeHash, readHash, serverHash);
const hashTsv = useMemo(() => decodeHash(hash), [hash]);
const [draft, setDraft] = useState<string | null>(null);
const [lastGood, setLastGood] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("table");
const [nav, setNav] = useState<{ sig: string; path: boolean[] }>({ sig: "", path: [] });
const [copied, setCopied] = useState<{ what: "embed" | "url" | "fail"; at: number } | null>(null);
const [codeOpen, setCodeOpen] = useState(false);
const [flash, setFlash] = useState<{ a: boolean; k: string } | null>(null);
const codeRef = useRef<HTMLTextAreaElement>(null);
const source = draft ?? hashTsv ?? initialTsv;
const parsed = useMemo(() => parseTable(source), [source]);
const goodTsv = useMemo(
() => pickGoodTsv({ source, lastGood, hashTsv, initialTsv }),
[source, lastGood, hashTsv, initialTsv],
);
const table = useMemo(() => {
const p = parseTable(goodTsv);
return p.ok ? p.table : (parseTable(DEFAULT_TSV) as { ok: true; table: PlanTable }).table;
}, [goodTsv]);
const built = useMemo(() => buildTree(table), [table]);
const sum = useMemo(() => summarize(table, built), [table, built]);
const embed = useMemo(() => buildEmbed(table, built), [table, built]);
const embedKb = useMemo(() => (new TextEncoder().encode(embed).length / 1024).toFixed(1), [embed]);
const sig = goodTsv;
const path = nav.sig === sig ? nav.path : [];
const node = nodeAt(built.node, path);
const errors = parsed.ok ? [] : parsed.errors;
const sampleId = SAMPLES.find((s) => s.tsv === source)?.id;
const focusNext = useRef(false);
const qRef = useRef<HTMLElement>(null);
const keyOf = (p: boolean[]) => `${sig.length}:${p.map((a) => (a ? 1 : 0)).join("")}`;
const pathKey = keyOf(path);
useEffect(() => {
if (!focusNext.current) return;
focusNext.current = false;
qRef.current?.focus();
}, [pathKey]);
const go = (next: boolean[], focus = true) => {
focusNext.current = focus;
setNav({ sig, path: next });
};
const answer = (a: boolean) => {
setFlash({ a, k: keyOf([...path, a]) });
go([...path, a]);
};
const back = () => path.length && go(path.slice(0, -1));
const writeHash = (v: string) => {
const url = `${window.location.pathname}${window.location.search}#yn=${encodeHash(v)}`;
window.history.replaceState(window.history.state, "", url);
};
const edit = (v: string) => {
setDraft(v);
if (parseTable(v).ok) {
setLastGood(v);
writeHash(v);
}
};
const copy = async (text: string, what: "embed" | "url") => {
try {
await navigator.clipboard.writeText(text);
setCopied({ what, at: Date.now() });
} catch {
setCopied({ what: "fail", at: Date.now() });
// コピーできない環境(権限・http・埋め込みの iframe)では、コードの欄を開いて手で選べるようにする
if (what === "embed") setCodeOpen(true);
}
};
const selectCode = () => {
setCodeOpen(true);
requestAnimationFrame(() => {
const el = codeRef.current;
if (!el) return;
el.scrollIntoView({ block: "nearest" });
el.focus();
el.select();
});
};
const shareUrl = () => `${PAGE_URL}#yn=${encodeHash(goodTsv)}`;
const onTabKey = (e: KeyboardEvent, i: number) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
e.stopPropagation();
const next = TABS[(i + d + TABS.length) % TABS.length].id;
setTab(next);
document.getElementById(`${uid}-tab-${next}`)?.focus();
};
const onPreviewKey = (e: KeyboardEvent) => {
if (e.key !== "ArrowLeft" || e.altKey || e.ctrlKey || e.metaKey || !path.length) return;
e.preventDefault();
back();
};
const name = (j: number) => table.plans[j].name;
const quote = (js: number[]) => js.map((j) => `「${name(j)}」`).join("と");
// 問いの道(答えた順の問い)
const trail: { q: string; a: boolean }[] = [];
{
let n = built.node;
for (const a of path) {
if (n.kind !== "q") break;
trail.push({ q: questionOf(table.features[n.f].name), a });
n = a ? n.yes : n.no;
}
}
// 表のどの行がいまの問いか・残っているプラン(プレビュー冒頭の小さな○×表で、表と問いを対応づける)
const remaining = new Set<number>();
walk(node, (n) => {
if (n.kind === "r") n.plans.forEach((j) => remaining.add(j));
});
const answered = new Map<number, { i: number; a: boolean }>();
{
let n = built.node;
path.forEach((a, i) => {
if (n.kind !== "q") return;
answered.set(n.f, { i, a });
n = a ? n.yes : n.no;
});
}
const current = node.kind === "q" ? node.f : -1;
const flashOn = flash && flash.k === pathKey ? flash.a : null;
const anim = path.length ? "animate-[yn-in_120ms_ease-out] motion-reduce:animate-none" : "";
return (
<section
className="@container mx-auto w-full max-w-6xl border border-stone-200 bg-white text-base text-stone-900"
aria-labelledby={`${uid}-title`}
>
<style>{KEYFRAMES}</style>
{/* 見出しと結論(何問で選べるか)。広い幅では1段にまとめ、すぐ下の小さな○×表まで最初の画面に入れる */}
<div className="grid @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end" aria-live="polite">
<div className="px-5 py-3.5 @2xl:px-8 @2xl:py-5">
<p className="hidden font-display text-xs font-semibold tracking-[0.18em] text-stone-500 @2xl:block">
BLK-108<span className="mx-2 inline-block h-px w-5 bg-stone-300 align-middle" />
<span className="font-sans tracking-normal">料金・プラン診断</span>
</p>
<Heading
id={`${uid}-title`}
className="max-w-3xl text-[17px] font-bold leading-[1.45] tracking-tight text-balance [word-break:auto-phrase] @2xl:mt-2 @2xl:text-[clamp(1.2rem,2.6vw,1.6rem)]"
>
料金表の○×から質問を組む
</Heading>
</div>
<div className="summary flex items-center gap-x-3 border-t border-stone-200 px-5 py-2 @2xl:my-4 @2xl:items-end @2xl:gap-x-5 @2xl:border-l @2xl:border-t-0 @2xl:px-8 @2xl:py-1">
<p className="shrink-0 leading-none">
<span className="mr-1 text-[13px] font-bold text-stone-600 @2xl:text-sm">最短</span>
<span className="font-display text-[2.25rem] font-bold leading-none tabular-nums text-stone-900 @2xl:text-[3.5rem]">
{built.max}
</span>
<span className="ml-1 text-[15px] font-bold @2xl:text-lg">{sum.groups.length ? "問で絞れる" : "問で選べる"}</span>
</p>
<p className="min-w-0 text-xs leading-snug text-stone-600 [word-break:auto-phrase] @2xl:mb-1 @2xl:text-sm">
<span>{table.plans.length}プラン・平均{sum.avg.toFixed(1)}問</span>
{!sum.groups.length && (
<span className="block font-bold text-stone-800 @2xl:mt-0.5">
<span aria-hidden="true" className="mr-1 text-[#b45309]">✓</span>全プランを区別できます
</span>
)}
</p>
</div>
{sum.groups.length > 0 && (
// 区別できないプランは、幅に関わらず帯をまたいで大きく出す
<div className="border-t border-stone-200 px-5 py-4 @2xl:col-span-2 @2xl:px-8">
<div className="warn border-l-4 border-[#d97706] bg-amber-50 py-3 pl-4 pr-3">
<p className="text-lg font-bold leading-snug text-stone-900">
{sum.groups.length === 1 ? quote(sum.groups[0]) : `${sum.groups.length}組のプラン`}は質問で区別できません
</p>
<ul className="mt-1 space-y-1 text-[15px] leading-relaxed text-stone-700">
{sum.groups.map((g) => (
<li key={g.join()}>
{quote(g)}は表の○×がすべて同じです。違いの出る機能を1行足すと分けられます
</li>
))}
</ul>
</div>
</div>
)}
{errors.length > 0 && (
<p className="border-t border-stone-200 px-5 py-3 @2xl:col-span-2 @2xl:px-8">
<span className="block border-l-4 border-red-600 py-1 pl-4 text-[15px] font-bold leading-relaxed text-red-700">
表に誤りが{errors.length}件あります。診断は直前の正しい表のまま表示しています
</span>
</p>
)}
</div>
<div className="grid border-t border-stone-200 @2xl:grid-cols-[minmax(0,9fr)_minmax(0,11fr)]">
{/* 診断プレビュー */}
<div
role="region"
aria-label="診断プレビュー"
onKeyDown={onPreviewKey}
className="preview bg-stone-50/60 px-5 pb-6 pt-3 @2xl:border-r @2xl:border-stone-200 @2xl:px-7 @2xl:pt-6"
>
<div className="@2xl:sticky @2xl:top-24">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-bold text-stone-600">診断プレビュー</p>
<p key={pathKey} className={`font-display text-sm tabular-nums text-stone-500 ${anim}`}>
{node.kind === "q" ? `質問 ${path.length + 1} / 最大 ${built.max}` : `${path.length}問で決定`}
</p>
</div>
<div className="mt-2 flex gap-1" aria-hidden="true">
{Array.from({ length: Math.max(built.max, 1) }, (_, i) => (
<span
key={i}
className={`h-1 flex-1 transition-colors duration-[120ms] motion-reduce:transition-none ${
i < path.length ? "bg-[#d97706]" : "bg-stone-200"
}`}
/>
))}
</div>
<MiniTable
table={table}
used={sum.used}
same={sum.same}
answered={answered}
current={current}
currentNo={path.length + 1}
remaining={remaining}
done={node.kind === "r"}
/>
<div className="mt-3 @2xl:mt-4 @2xl:min-h-52">
<div key={pathKey} className={anim}>
{node.kind === "q" ? (
<QTag
ref={qRef as never}
tabIndex={-1}
className="question text-[clamp(1.2rem,3.2cqw,1.6rem)] font-bold leading-snug text-balance outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d97706] [word-break:auto-phrase]"
>
<span className="mr-2 inline-block bg-[#d97706] px-1.5 align-[0.15em] font-display text-xs leading-5 text-white">
Q{path.length + 1}
</span>
{questionOf(table.features[node.f].name)}
</QTag>
) : (
<div className="result">
<p className="text-sm text-stone-600">
{node.plans.length > 1 ? "表の○×では、次のどれかまで絞れました" : "おすすめのプラン"}
</p>
<QTag
ref={qRef as never}
tabIndex={-1}
className="plan-name relative mt-1 inline-block pb-1 text-[clamp(1.6rem,4.5cqw,2.25rem)] font-bold leading-tight outline-none animate-[yn-pop_200ms_ease-out] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d97706] motion-reduce:animate-none"
>
{node.plans.map(name).join("・")}
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1 origin-left bg-[#d97706] animate-[yn-bar_240ms_ease-out] motion-reduce:animate-none"
/>
</QTag>
<div className="mt-4 flex flex-wrap gap-2">
{node.plans.map((j) =>
table.plans[j].link ? (
<a
key={j}
href={table.plans[j].link}
className={`see-plan inline-flex min-h-12 items-center bg-amber-700 px-5 font-bold text-white transition-colors animate-[yn-cta_600ms_ease-out_120ms_both] hover:bg-amber-800 active:bg-amber-900 motion-reduce:animate-none motion-reduce:transition-none ${focusRing}`}
>
{node.plans.length > 1 ? `${name(j)}を見る` : "このプランを見る"}
</a>
) : null,
)}
</div>
{node.plans.every((j) => !table.plans[j].link) && (
<p className="mt-3 text-sm leading-relaxed text-stone-600">
表に「リンク先」の行を足すと「このプランを見る」ボタンが出ます
</p>
)}
</div>
)}
</div>
{node.kind === "q" && (
// 答えのボタンは問いが替わっても作り直さない(押した側だけ短く光らせて、受け付けたことを示す)
<div className="mt-3 grid grid-cols-2 gap-2 @2xl:mt-4">
{([true, false] as const).map((a) => (
<button
key={`${a}-${flashOn === a ? pathKey : ""}`}
type="button"
onClick={() => answer(a)}
className={`${a ? "answer-yes" : "answer-no"} min-h-14 border border-stone-300 bg-white px-4 text-lg font-bold transition-[background-color,border-color,transform] duration-100 hover:border-[#d97706] hover:bg-amber-50 active:translate-y-px active:border-[#d97706] active:bg-amber-100 motion-reduce:transition-none motion-reduce:active:translate-y-0 ${
flashOn === a ? "animate-[yn-flash_320ms_ease-out] motion-reduce:animate-none" : ""
} ${focusRing}`}
>
{a ? "はい" : "いいえ"}
</button>
))}
</div>
)}
{trail.length > 0 && (
<ol className="mt-5 space-y-1 text-sm leading-relaxed text-stone-600">
{trail.map((s, i) => (
<li key={i} className="border-l-2 border-[#d97706] pl-3">
{s.q} <b className="font-bold text-stone-900">{s.a ? "はい" : "いいえ"}</b>
</li>
))}
</ol>
)}
</div>
<div className="mt-6 flex flex-wrap items-center gap-x-6 border-t border-stone-200 pt-2">
<button
type="button"
onClick={back}
disabled={!path.length}
className={`back min-h-11 text-[15px] underline underline-offset-4 disabled:text-stone-400 disabled:no-underline ${focusRing}`}
>
1問戻る
</button>
<button
type="button"
onClick={() => go([])}
disabled={!path.length}
className={`restart min-h-11 text-[15px] underline underline-offset-4 disabled:text-stone-400 disabled:no-underline ${focusRing}`}
>
最初から
</button>
<span className="ml-auto hidden text-xs text-stone-500 @md:inline">←キーでも1問戻れます</span>
</div>
</div>
</div>
{/* 表・質問の木・出力 */}
<div className="@container min-w-0 border-t border-stone-200 @2xl:border-t-0">
<div role="tablist" aria-label="表示の切り替え" className="grid grid-cols-3 border-b border-stone-200">
{TABS.map((t, i) => {
const on = t.id === tab;
return (
<button
key={t.id}
id={`${uid}-tab-${t.id}`}
type="button"
role="tab"
aria-selected={on}
aria-controls={`${uid}-panel`}
tabIndex={on ? 0 : -1}
data-tab={t.id}
onClick={() => setTab(t.id)}
onKeyDown={(e) => onTabKey(e, i)}
className={`relative min-h-12 border-r border-stone-200 px-3 text-[15px] font-bold last:border-r-0 outline-none focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#d97706] ${
on ? "bg-white text-stone-900" : "bg-stone-50 text-stone-500 hover:text-stone-900"
}`}
>
{t.label}
{t.id === "table" && errors.length > 0 && (
<span className="ml-1.5 font-display text-xs tabular-nums text-red-700">{errors.length}</span>
)}
<span
aria-hidden="true"
className={`absolute inset-x-0 bottom-0 h-0.5 origin-left bg-[#d97706] transition-transform duration-150 motion-reduce:transition-none ${on ? "scale-x-100" : "scale-x-0"}`}
/>
</button>
);
})}
</div>
<div
key={tab}
id={`${uid}-panel`}
role="tabpanel"
aria-labelledby={`${uid}-tab-${tab}`}
className="animate-[yn-fade_120ms_ease-out] px-5 py-6 motion-reduce:animate-none @lg:px-7"
>
{tab === "table" && (
<TablePanel
uid={uid}
source={source}
sampleId={sampleId}
errors={errors}
table={table}
used={sum.used}
same={sum.same}
onEdit={edit}
/>
)}
{tab === "tree" && (
<div>
<p className="text-sm leading-relaxed text-stone-600">
問いを押すと、左の診断がその問いから始まります。選んだ道はアンバーで示します
</p>
<ul className="tree mt-4 text-[15px] leading-relaxed">
<TreeItem node={built.node} at={[]} cur={path} table={table} onJump={(p) => go(p, false)} />
</ul>
</div>
)}
{tab === "output" && (
<div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => copy(embed, "embed")}
className={`copy-embed min-h-12 bg-stone-900 px-5 font-bold text-white transition-colors hover:bg-amber-800 active:bg-amber-900 motion-reduce:transition-none ${focusRing}`}
>
LPに貼る
</button>
<button
type="button"
onClick={() => copy(shareUrl(), "url")}
className={`copy-url min-h-12 border border-stone-300 bg-white px-5 font-bold transition-colors hover:border-[#d97706] active:bg-amber-50 motion-reduce:transition-none ${focusRing}`}
>
この表のURLをコピー
</button>
</div>
<div className="mt-2 min-h-6 text-sm text-stone-700" aria-live="polite">
{copied?.what === "embed" && <p>HTMLをコピーしました({embedKb}KB)。貼る先の手順は下のとおりです</p>}
{copied?.what === "url" && <p>この表を開くURLをコピーしました</p>}
{copied?.what === "fail" && (
<div className="copy-fail border-l-4 border-[#d97706] bg-amber-50 py-2 pl-4 pr-3">
<p className="font-bold text-stone-900">
このブラウザでは自動でコピーできませんでした。下の欄のコードを選んでコピーしてください
</p>
<button
type="button"
onClick={selectCode}
className={`select-code mt-2 min-h-11 border border-[#d97706] bg-white px-4 font-bold text-amber-900 transition-colors hover:bg-amber-100 active:bg-amber-200 motion-reduce:transition-none ${focusRing}`}
>
コードを選択
</button>
</div>
)}
</div>
<dl className="mt-4 divide-y divide-stone-200 border-y border-stone-200 text-[15px] leading-relaxed">
<OutRow k="中身">div・style・script の1かたまり({embedKb}KB)。外部の読み込みは無く、クラスはすべて yn- で始まります</OutRow>
<OutRow k="静的HTML">body の中の置きたい位置に貼ります</OutRow>
<OutRow k="WordPress">カスタムHTMLブロックに貼ります。script を保存できる権限(管理者など)のアカウントで保存してください</OutRow>
<OutRow k="リンク">各プランの「リンク先」の行で決まります。https:// ・ / ・ # で始まるものだけ使えます</OutRow>
</dl>
<p className="mt-5 text-sm font-bold text-stone-600">書き出したHTMLの表示</p>
<iframe
title="書き出したHTMLの表示"
sandbox="allow-scripts"
srcDoc={`<!doctype html><html lang="ja"><meta charset="utf-8"><body style="margin:0;padding:12px;font-family:system-ui,sans-serif">${embed}</body></html>`}
className="mt-2 h-80 w-full border border-stone-200 bg-white"
/>
<details
className="group mt-4"
open={codeOpen}
onToggle={(e) => setCodeOpen((e.currentTarget as HTMLDetailsElement).open)}
>
<summary className={`flex min-h-11 cursor-pointer items-center gap-2 text-[15px] font-bold ${focusRing}`}>
<span aria-hidden="true" className="font-display inline-block w-3 text-center group-open:hidden">+</span>
<span aria-hidden="true" className="font-display hidden w-3 text-center group-open:inline-block">−</span>
コードを見る
</summary>
<label htmlFor={`${uid}-code`} className="sr-only">
書き出したHTML
</label>
<textarea
id={`${uid}-code`}
ref={codeRef}
readOnly
value={embed}
spellCheck={false}
rows={12}
onFocus={(e) => e.currentTarget.select()}
className="embed-code mt-2 block w-full resize-y whitespace-pre border border-stone-200 bg-stone-50 p-3 font-mono text-xs leading-relaxed outline-none focus-visible:border-[#d97706] focus-visible:outline-2 focus-visible:outline-[#d97706]"
/>
</details>
</div>
)}
</div>
</div>
</div>
</section>
);
}
/** プレビュー冒頭の小さな○×表。質問に使う行・いまの問いの行・残っているプランを、問いと同じアンバーで示す */
function MiniTable({
table,
used,
same,
answered,
current,
currentNo,
remaining,
done,
}: {
table: PlanTable;
used: Set<number>;
same: boolean[];
answered: Map<number, { i: number; a: boolean }>;
current: number;
currentNo: number;
remaining: Set<number>;
done: boolean;
}) {
return (
<div className="mt-3">
<table className="minitab w-full table-fixed border-collapse text-[13px] leading-snug">
<caption className="sr-only">料金表の○×と、質問に使う行</caption>
<colgroup>
{/* 機能名の列はプランが増えるほど細くする(3プランで46%・5プランで34%)。名前は切らずに折り返す */}
<col style={{ width: `${Math.max(34, 64 - table.plans.length * 6)}%` }} />
</colgroup>
<thead>
<tr className="border-b border-stone-300">
<th scope="col" className="py-1 pr-2 text-left text-xs font-bold text-stone-500">
<span className="hidden @2xl:inline">料金表 → 質問</span>
{/* 狭い幅は質問に使う行だけを出すので、省いた行の数を見出しで示す */}
<span className="[word-break:auto-phrase] @2xl:hidden">
{table.features.length}行中{used.size}行を質問に
</span>
</th>
{table.plans.map((p, j) => {
const on = remaining.has(j);
return (
<th
key={p.name}
scope="col"
title={p.name}
className={`px-0.5 py-1 text-center text-[11px] font-bold leading-tight [overflow-wrap:anywhere] transition-[opacity,background-color] duration-150 motion-reduce:transition-none ${
on ? (done ? "bg-[#d97706] text-white" : "text-stone-800") : "text-stone-400 opacity-40"
}`}
>
{p.name}
</th>
);
})}
</tr>
</thead>
<tbody>
{table.features.map((f, i) => {
const u = used.has(i);
const now = i === current;
const ans = answered.get(i);
return (
<tr
key={f.name}
className={`border-b border-stone-200 transition-colors duration-150 motion-reduce:transition-none ${
now ? "bg-amber-100" : ""
} ${u ? "" : "hidden @2xl:table-row"}`}
>
<th scope="row" className={`py-1 pr-2 text-left font-medium ${now ? "border-l-4 border-[#d97706] pl-1.5" : "pl-0"}`}>
<span className="flex min-w-0 items-start gap-1.5">
{now ? (
<span className="shrink-0 bg-[#d97706] px-1 font-display text-[11px] leading-4 text-white">Q{currentNo}</span>
) : ans ? (
<span className="shrink-0 border border-[#d97706] px-1 font-display text-[11px] leading-[14px] text-amber-800">
Q{ans.i + 1}
</span>
) : null}
<span
className={`min-w-0 [overflow-wrap:anywhere] [word-break:auto-phrase] ${now ? "font-bold text-amber-950" : u ? "text-stone-800" : same[i] ? "text-stone-400" : "text-stone-500"}`}
>
{f.name}
</span>
</span>
</th>
{f.marks.map((m, j) => (
<td
key={j}
className={`py-1 text-center transition-opacity duration-150 motion-reduce:transition-none ${
remaining.has(j) ? "" : "opacity-30"
} ${now && m ? "font-bold text-amber-900" : m ? (u ? "text-stone-900" : "text-stone-400") : "text-stone-300"}`}
>
<span aria-label={m ? "あり" : "なし"}>{m ? "○" : "×"}</span>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function OutRow({ k, children }: { k: string; children: ReactNode }) {
return (
<div className="grid gap-1 py-2.5 @md:grid-cols-[7rem_minmax(0,1fr)]">
<dt className="font-bold">{k}</dt>
<dd className="text-stone-700">{children}</dd>
</div>
);
}
function TablePanel({
uid,
source,
sampleId,
errors,
table,
used,
same,
onEdit,
}: {
uid: string;
source: string;
sampleId?: string;
errors: string[];
table: PlanTable;
used: Set<number>;
same: boolean[];
onEdit: (v: string) => void;
}) {
return (
<div>
<p className="text-sm font-bold text-stone-600">見本(架空の3業種)</p>
<ul className="mt-2 grid border-l border-t border-stone-200 @md:grid-cols-3">
{SAMPLES.map((s) => {
const on = s.id === sampleId;
return (
<li key={s.id} className={`flex items-center justify-between gap-2 border-b border-r border-stone-200 py-2 pl-3 pr-2 ${on ? "bg-amber-50" : ""}`}>
<span className="leading-tight">
<span className="block text-[15px] font-bold">{s.label}</span>
<span className="text-xs text-stone-500">{s.size}</span>
</span>
<button
type="button"
data-sample={s.id}
aria-pressed={on}
onClick={() => onEdit(s.tsv)}
className={`min-h-11 shrink-0 border px-3 text-sm font-bold transition-colors motion-reduce:transition-none ${
on ? "border-[#d97706] bg-white text-amber-800" : "border-stone-300 bg-white hover:border-[#d97706]"
} ${focusRing}`}
>
{on ? "表示中" : "この型を使う"}
</button>
</li>
);
})}
</ul>
<label htmlFor={`${uid}-tsv`} className="mt-5 block text-sm font-bold text-stone-600">
料金表(スプレッドシートの範囲をコピーして貼る)
</label>
<textarea
id={`${uid}-tsv`}
value={source}
onChange={(e) => onEdit(e.target.value)}
spellCheck={false}
rows={8}
aria-describedby={`${uid}-help ${uid}-errors`}
aria-invalid={errors.length > 0}
className={`tsv mt-2 block w-full resize-y whitespace-pre border bg-white p-3 font-mono text-[13px] leading-relaxed [tab-size:12] ${
errors.length ? "border-red-600" : "border-stone-300"
} outline-none focus-visible:border-[#d97706] focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-[#d97706]`}
/>
<p id={`${uid}-help`} className="mt-2 text-sm leading-relaxed text-stone-600">
1行目はプラン名、2行目からは機能名と○/×。「リンク先」の行は任意です。{MAX_PLANS}プラン×{MAX_FEATURES}機能まで、各セル{MAX_CELL}字まで(リンク先は{MAX_LINK}字)
</p>
<div id={`${uid}-errors`} aria-live="polite">
{errors.length > 0 && (
<ul className="errors mt-3 space-y-1 border-l-4 border-red-600 py-1 pl-4 text-[15px] leading-relaxed text-red-700">
{errors.slice(0, 8).map((e) => (
<li key={e}>{e}</li>
))}
{errors.length > 8 && <li>ほか{errors.length - 8}件</li>}
</ul>
)}
</div>
<p className="mt-6 text-sm font-bold text-stone-600">
{errors.length ? "直前の正しい表" : "読み取った表"}
</p>
<div className="mt-2 overflow-x-auto" role="region" aria-label="読み取った表" tabIndex={0}>
<table className="w-full min-w-[22rem] border-collapse text-[14px]">
<thead>
<tr className="border-b border-stone-300">
<th scope="col" className="py-2 pr-3 text-left font-bold text-stone-500">機能</th>
{table.plans.map((p) => (
<th key={p.name} scope="col" className="px-2 py-2 text-center font-bold">
{p.name}
</th>
))}
<th scope="col" className="py-2 pl-3 text-left font-bold text-stone-500">質問</th>
</tr>
</thead>
<tbody>
{table.features.map((f, i) => {
const s = same[i];
const u = used.has(i);
return (
<tr key={f.name} className={`border-b border-stone-200 ${s ? "bg-stone-50 text-stone-400" : ""}`}>
<th scope="row" className={`py-2 pr-3 text-left font-medium ${u ? "text-stone-900" : ""}`}>
{f.name}
</th>
{f.marks.map((m, j) => (
<td key={j} className={`px-2 py-2 text-center ${s ? "" : m ? "text-stone-900" : "text-stone-400"}`}>
<span aria-label={m ? "あり" : "なし"}>{m ? "○" : "×"}</span>
</td>
))}
<td className="whitespace-nowrap py-2 pl-3 text-[13px]">
{s ? (
<span>質問不要</span>
) : u ? (
<span className="font-bold text-amber-800">使う</span>
) : (
<span className="text-stone-500">無くても選べる</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
function TreeItem({
node,
at,
cur,
table,
onJump,
answer,
}: {
node: TreeNode;
at: boolean[];
cur: boolean[];
table: PlanTable;
onJump: (p: boolean[]) => void;
answer?: boolean;
}) {
const onPath = at.length <= cur.length && at.every((a, i) => a === cur[i]);
const here = onPath && at.length === cur.length;
const label =
answer === undefined ? null : (
<span className={`mr-2 font-bold ${onPath ? "text-amber-800" : "text-stone-500"}`}>{answer ? "はい" : "いいえ"}</span>
);
if (node.kind === "r") {
const many = node.plans.length > 1;
return (
<li className="py-1">
{label}
<button
type="button"
onClick={() => onJump(at)}
aria-current={here ? "step" : undefined}
className={`min-h-9 px-1 text-left ${focusRing} ${
onPath ? "bg-amber-50 font-bold text-amber-900" : many ? "text-stone-900" : "text-stone-800"
}`}
>
{node.plans.map((j) => table.plans[j].name).join("・")}
{many && <span className="ml-2 text-xs font-bold text-amber-800">区別できない</span>}
</button>
</li>
);
}
return (
<li className="py-1">
{label}
<button
type="button"
onClick={() => onJump(at)}
aria-current={here ? "step" : undefined}
className={`min-h-9 px-1 text-left ${focusRing} ${onPath ? "bg-amber-50 font-bold text-amber-900" : "text-stone-900"}`}
>
<span className="font-display mr-1.5 text-xs tabular-nums text-stone-500">Q{at.length + 1}</span>
{questionOf(table.features[node.f].name)}
</button>
<ul className={`ml-2 mt-1 border-l pl-4 ${onPath && cur.length > at.length ? "border-[#d97706]" : "border-stone-200"}`}>
<TreeItem node={node.yes} at={[...at, true]} cur={cur} table={table} onJump={onJump} answer />
<TreeItem node={node.no} at={[...at, false]} cur={cur} table={table} onJump={onJump} answer={false} />
</ul>
</li>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/yesno-chart-flow.json