익스클루시브
Phrasebook <mv-phrasebook>
자유 텍스트가 위험하거나 비용이 큰 곳을 위한 제한형 메시지 작성기입니다. 사용자는 템플릿(“{thing} was {quality}”, “Check {thing} before ordering”, “{action} helps”)을 고르고 각 빈칸을 고정된 분류별 어휘에서 골라 채우는 방식으로만 글을 쓰며, 원하면 접속어(“but”, “so”)로 두 번째 구절을 이을 수 있습니다.
문화적 레퍼런스
데몬즈 소울, FromSoftware(감독: 미야자키 히데타카) (2009, 게임). 플레이어는 낯선 사람에게 메시지를 남길 수 있지만, 고정된 템플릿과 고정된 어휘의 단어를 조합하는 방식으로만 가능합니다. 덕분에 모든 메시지가 안전하고 짧으며 어느 언어로든 읽을 수 있고, 다른 플레이어가 이를 평가합니다. UI에서는 그 메커니즘만 앱이 작성한 템플릿과 단어로 가져왔습니다. 메시지는 앱이 선언한 템플릿과 단어 목록으로 작성되고, id로 저장되어 각 독자가 자신의 언어로 읽으며, 도움이 되는지 여부로 평가됩니다.
작동 방식
자유 텍스트가 위험하거나 비용이 큰 곳을 위한 제한형 메시지 작성기입니다. 사용자는 템플릿(“{thing} was {quality}”, “Check {thing} before ordering”, “{action} helps”)을 고르고 각 빈칸을 고정된 분류별 어휘에서 골라 채우는 방식으로만 글을 쓰며, 원하면 접속어(“but”, “so”)로 두 번째 구절을 이을 수 있습니다. 모든 빈칸, 접속어, 템플릿 전환은 실제 버튼이며, 검색 가능하고 그룹으로 묶인 단어 선택기(combobox + listbox, 표시 언어와 원본 언어 모두에서 악센트를 구분하지 않는 검색, 분류 필터, 원본 언어 주석)를 엽니다. 단어를 고르면 다음 빈칸으로 자동으로 넘어가고, 템플릿을 바꿔도 단어가 유지되며, 실시간 미리보기가 문장과 진행 상황(“1 blank left”), 읽을 수 있는 언어(점선 칩은 부분 번역을 뜻함)를 보여 줍니다. 값은 텍스트가 아닌 구조화된 데이터({ template, slots: { thing: "delivery" }, conjunction, next })이므로, 앱은 id를 저장하고 언어별 어순과 문장 부호(“{first}。{conjunction}、{second}”)를 담은 메시지 사전을 통해 어떤 로케일로든 메시지를 렌더링합니다. 직접 입력한 내용이 없으므로 검열이나 기계 번역이 필요 없습니다. 폼 연동(JSON 폼 값, required, 완성되지 않은 메시지는 유효하지 않음), <option>/<optgroup> 자식, JSON <script> 또는 속성에서 가져오는 어휘, 선택을 거부할 수 있는 취소 가능한 mv-pick, 그리고 함께 쓰는 <mv-phrasebook-message> 읽기 뷰(모든 로케일, 취소 가능하고 await할 수 있는 mv-rate를 통한 도움됨 / 도움 안 됨 투표)를 지원합니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-phrasebook>) |
| 상태 | 안정 |
| Keywords | exclusive, culture, composer, message, feedback, review, templates, vocabulary, constrained-input, moderation, safety, i18n, translation, structured-value, sentence-builder, combobox, vote, helpful, marketplace, community |
When to use
- Buyers leave seller feedback on a marketplace and reviews must stay short, safe and readable by buyers in every language
- A kids' or gaming community lets strangers message each other, and free text would need costly moderation
- Anonymous peer or retro feedback should stay constructive, with no room for insults or identifying details
- Support agents or teammates who share no language exchange quick structured replies rendered in each reader's locale
Avoid when
- People need to say something specific in their own words, such as a detailed product review → use Textarea instead
- The sentence is a filter or automation rule evaluated by the app rather than a message read by people → use Word Rules instead
- A single overall score is enough, with no message attached → use Rating instead
설치
node scripts/add.mjs phrasebook --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["phrasebook"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/form.js, core/motion.js, core/position.js, components/phrasebook/phrasebook.js, components/phrasebook/phrasebook.css.
사용법
기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="pb-demo" style="width:min(100%,70rem);margin-inline:auto">
<style>
#pb-demo { display:grid; gap:1.25rem }
#pb-demo .pb-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#pb-demo .pb-main { display:grid; grid-template-columns:minmax(0,1.3fr) minmax(0,1fr) }
#pb-demo .pb-compose { display:grid; gap:.875rem; align-content:start; margin:0; padding:1.25rem; border-inline-end:1px solid var(--mv-border) }
#pb-demo .pb-order { display:flex; align-items:center; gap:.75rem; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#pb-demo .pb-thumb { display:grid; place-items:center; flex:none; width:2.75rem; height:2.75rem; border-radius:var(--mv-radius-md); background:color-mix(in oklab, var(--mv-success) 18%, var(--mv-surface)); color:color-mix(in oklab, var(--mv-success), var(--mv-fg) 35%) }
#pb-demo .pb-thumb svg { width:1.35rem; height:1.35rem }
#pb-demo .pb-order-text { display:grid; gap:.125rem; flex:1; min-width:0 }
#pb-demo .pb-order-text strong { font-size:.875rem; letter-spacing:-.01em }
#pb-demo .pb-order-text span { color:var(--mv-fg-muted); font-size:.75rem }
#pb-demo .pb-price { font-size:.875rem; font-weight:600; font-variant-numeric:tabular-nums }
#pb-demo .pb-field { display:grid; gap:.375rem }
#pb-demo .pb-label { font-size:.875rem; font-weight:600; letter-spacing:-.01em }
#pb-demo .pb-help { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#pb-demo .pb-actions { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#pb-demo .pb-toggle { display:inline-flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem; cursor:pointer }
#pb-demo .pb-side { display:grid; gap:1rem; align-content:start; padding:1.25rem; background:var(--mv-bg-subtle); border-radius:0 var(--mv-radius-xl) var(--mv-radius-xl) 0 }
#pb-demo .pb-side h4, #pb-demo .pb-feed-head h4 { margin:0; font-size:.875rem; letter-spacing:-.01em }
#pb-demo .pb-side > p { margin:-.625rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.45 }
#pb-demo .pb-langs { display:grid; gap:.5rem }
#pb-demo .pb-lang { display:grid; grid-template-columns:4.75rem minmax(0,1fr); gap:.75rem; align-items:baseline; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#pb-demo .pb-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
#pb-demo .pb-lang mv-phrasebook-message { --mv-phrasebook-message-size:.9375rem }
#pb-demo .pb-lang:has(mv-phrasebook-message[data-empty])::after { content:"-"; color:var(--mv-fg-subtle) }
#pb-demo pre { margin:.375rem 0 0; padding:.625rem .75rem; max-height:11rem; overflow:auto; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); color:var(--mv-fg-muted); font:400 .6875rem/1.55 var(--mv-font-mono); white-space:pre-wrap; word-break:break-word }
#pb-demo pre:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#pb-demo .pb-log { margin:0; min-height:1.1rem; color:var(--mv-fg-subtle); font:400 .75rem/1.5 var(--mv-font-mono) }
#pb-demo .pb-feed-card { padding:1.125rem 1.25rem 1.25rem }
#pb-demo .pb-feed-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin-bottom:.875rem }
#pb-demo .pb-feed-head > div { display:flex; align-items:center; gap:.5rem }
#pb-demo .pb-feed { display:grid; gap:.5rem; margin:0; padding:0; list-style:none }
#pb-demo .pb-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.25rem .75rem; align-items:start }
#pb-demo .pb-av { display:grid; place-items:center; width:2rem; height:2rem; margin-top:.25rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.6875rem; font-weight:600 }
#pb-demo .pb-who { display:flex; align-items:baseline; gap:.5rem; flex-wrap:wrap; font-size:.75rem }
#pb-demo .pb-who strong { font-size:.8125rem }
#pb-demo .pb-who span { color:var(--mv-fg-subtle) }
#pb-demo .pb-body { display:grid; gap:.25rem; min-width:0 }
#pb-demo .pb-item[data-new] mv-phrasebook-message { border-color:color-mix(in oklab, var(--mv-accent) 45%, var(--mv-border)) }
@media (max-width:52rem) {
#pb-demo .pb-main { grid-template-columns:minmax(0,1fr) }
#pb-demo .pb-compose { border-inline-end:0; border-bottom:1px solid var(--mv-border) }
#pb-demo .pb-side { border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
}
</style>
<!-- 1 · Marketplace seller feedback: structured, safe, readable in every language -->
<section class="pb-card pb-main" aria-label="Order feedback">
<form class="pb-compose" id="pb-form">
<div class="pb-order">
<span class="pb-thumb" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3 4 6l2 4 2-1v12h8V9l2 1 2-4-4-3c-.5 1.5-2 2.5-4 2.5S8.5 4.5 8 3Z"/></svg></span>
<span class="pb-order-text">
<strong>Linen overshirt · Sage, M</strong>
<span>Sold by Atelier Nord · Delivered Sep 18, 2026</span>
</span>
<span class="pb-price">$68.00</span>
</div>
<div class="pb-field">
<label class="pb-label" for="pb-book">Leave feedback for the seller</label>
<p class="pb-help" id="pb-help">Pick a phrase and fill the blanks. Buyers read it in their own language, and nothing needs moderation.</p>
</div>
<mv-phrasebook id="pb-book" name="feedback" required aria-describedby="pb-help"
value='{"template":"was","slots":{"thing":"delivery","quality":"on-time"},"conjunction":"but","next":{"template":"better","slots":{"thing":"packaging"}}}'>
<option data-template="was" data-group="Describe">{thing} was {quality}</option>
<option data-template="better" data-group="Describe">{thing} could be better</option>
<option data-template="check" data-group="Advise">Check {thing} before ordering</option>
<option data-template="helps" data-group="Advise">{action} helps</option>
<option data-template="great-for" data-group="Recommend">Great for {use}</option>
<option data-type="thing">aspect</option>
<option data-type="quality">quality</option>
<option data-type="action">tip</option>
<option data-type="use">occasion</option>
<optgroup label="Shipping" data-word="thing">
<option value="delivery">delivery</option>
<option value="packaging">packaging</option>
<option value="tracking">tracking</option>
</optgroup>
<optgroup label="Product" data-word="thing">
<option value="fabric" data-keywords="material linen">the fabric</option>
<option value="fit" data-keywords="size sizing">the fit</option>
<option value="color" data-keywords="colour shade">the color</option>
<option value="stitching" data-keywords="seams finish">the stitching</option>
</optgroup>
<optgroup label="Seller" data-word="thing">
<option value="communication" data-keywords="reply answer contact">communication</option>
<option value="returns" data-keywords="refund exchange">the return process</option>
<option value="value" data-keywords="price cost">value for money</option>
</optgroup>
<optgroup label="Positive" data-word="quality">
<option value="excellent">excellent</option>
<option value="flawless">flawless</option>
<option value="on-time" data-keywords="fast quick punctual">on time</option>
<option value="better-than-expected">better than expected</option>
</optgroup>
<optgroup label="Neutral" data-word="quality">
<option value="as-described">as described</option>
</optgroup>
<optgroup label="Negative" data-word="quality">
<option value="disappointing" data-keywords="bad poor">disappointing</option>
<option value="below-average" data-keywords="bad poor worse">below average</option>
</optgroup>
<optgroup label="Before you buy" data-word="action">
<option value="size-up">sizing up</option>
<option value="size-guide">reading the size guide</option>
<option value="ask-first" data-keywords="message contact question">messaging the seller first</option>
<option value="express" data-keywords="shipping fast">choosing express shipping</option>
</optgroup>
<optgroup label="After it arrives" data-word="action">
<option value="wash-cold" data-keywords="laundry care">washing it cold</option>
</optgroup>
<optgroup label="Occasions" data-word="use">
<option value="gifts">gifts</option>
<option value="everyday">everyday wear</option>
<option value="travel">travel</option>
<option value="warm-weather" data-keywords="summer heat">warm weather</option>
</optgroup>
<option data-conjunction="and">and</option>
<option data-conjunction="but">but</option>
<option data-conjunction="so">so</option>
<option data-conjunction="plus">plus</option>
<script type="application/json" data-phrasebook>
{ "messages": {
"es": {
"templates": { "was": "{thing} fue {quality}", "better": "{thing} podría mejorar", "check": "Revisa {thing} antes de pedir", "helps": "{action} ayuda", "great-for": "Ideal para {use}" },
"words": {
"delivery": "la entrega", "packaging": "el embalaje", "tracking": "el seguimiento",
"fabric": "la tela", "fit": "el ajuste", "color": "el color", "stitching": "la costura",
"communication": "la comunicación", "returns": "el proceso de devolución", "value": "la relación calidad-precio",
"excellent": "excelente", "flawless": "impecable", "on-time": "puntual", "better-than-expected": "mejor de lo esperado",
"as-described": "como se describía", "disappointing": "decepcionante", "below-average": "inferior a la media",
"size-up": "pedir una talla más", "size-guide": "leer la guía de tallas", "ask-first": "escribir antes al vendedor", "express": "elegir envío exprés", "wash-cold": "lavarla en frío",
"gifts": "regalar", "everyday": "el día a día", "travel": "viajar", "warm-weather": "el calor"
},
"conjunctions": { "and": "y", "but": "pero", "so": "así que", "plus": "además" }
},
"ja": {
"templates": { "was": "{thing}は{quality}でした", "better": "{thing}は改善の余地あり", "check": "注文前に{thing}を確認", "helps": "{action}がおすすめ", "great-for": "{use}におすすめ" },
"words": {
"delivery": "配送", "packaging": "梱包", "tracking": "追跡情報",
"fabric": "生地", "fit": "サイズ感", "color": "色", "stitching": "縫製",
"communication": "連絡", "returns": "返品対応", "value": "コスパ",
"excellent": "最高", "flawless": "完璧", "on-time": "時間どおり", "better-than-expected": "期待以上",
"as-described": "説明どおり", "disappointing": "期待外れ", "below-average": "平均以下",
"size-up": "ワンサイズ上", "size-guide": "サイズ表の確認", "ask-first": "購入前の問い合わせ", "express": "速達配送", "wash-cold": "冷水での洗濯",
"gifts": "ギフト", "everyday": "普段使い", "travel": "旅行", "warm-weather": "暑い日"
},
"conjunctions": { "and": "そして", "but": "でも", "so": "なので", "plus": "さらに" },
"format": { "join": "{first}。{conjunction}、{second}", "end": "。" }
}
} }
</script>
</mv-phrasebook>
<div class="pb-actions">
<label class="pb-toggle"><input type="checkbox" role="switch" class="mv-switch" id="pb-two" checked> Allow a second phrase</label>
<span style="display:flex;gap:.5rem">
<button type="reset" class="mv-button" data-variant="ghost">Reset</button>
<button type="submit" class="mv-button">Post feedback</button>
</span>
</div>
</form>
<aside class="pb-side" aria-labelledby="pb-side-title">
<h4 id="pb-side-title">How other buyers read it</h4>
<p>The same stored message, rendered from each reader's dictionary.</p>
<div class="pb-langs">
<div class="pb-lang"><span class="pb-k">English</span><mv-phrasebook-message id="pb-en" for="pb-book" locale="en" rating="none" data-variant="plain"></mv-phrasebook-message></div>
<div class="pb-lang"><span class="pb-k">Spanish</span><mv-phrasebook-message id="pb-es" for="pb-book" locale="es" rating="none" data-variant="plain"></mv-phrasebook-message></div>
<div class="pb-lang"><span class="pb-k">Japanese</span><mv-phrasebook-message id="pb-ja" for="pb-book" locale="ja" rating="none" data-variant="plain"></mv-phrasebook-message></div>
</div>
<div>
<span class="pb-k" id="pb-json-label">Stored value</span>
<pre id="pb-json" tabindex="0" aria-labelledby="pb-json-label"></pre>
</div>
<p class="pb-log" id="pb-log" aria-live="polite"></p>
</aside>
</section>
<!-- 2 · The seller's feed: every message rendered in the reader's language, rated helpful or not -->
<section class="pb-card pb-feed-card" aria-labelledby="pb-feed-title">
<header class="pb-feed-head">
<div>
<h4 id="pb-feed-title">Recent feedback · Atelier Nord</h4>
<span class="mv-badge" data-variant="secondary" id="pb-count">3 messages</span>
</div>
<mv-segmented id="pb-readas" label="Read feedback in" value="en">
<button value="en">English</button>
<button value="es">Spanish</button>
<button value="ja">Japanese</button>
</mv-segmented>
</header>
<ul class="pb-feed" id="pb-feed">
<li class="pb-item">
<span class="pb-av" aria-hidden="true">PS</span>
<div class="pb-body">
<span class="pb-who"><strong>Priya Shah</strong><span>Toronto · Sep 12, 2026</span></span>
<mv-phrasebook-message for="pb-book" locale="en" up="41" down="2" vote="up"
value='{"template":"helps","slots":{"action":"size-up"}}'></mv-phrasebook-message>
</div>
</li>
<li class="pb-item">
<span class="pb-av" aria-hidden="true">KM</span>
<div class="pb-body">
<span class="pb-who"><strong>Kenji Morita</strong><span>Osaka · Sep 9, 2026</span></span>
<mv-phrasebook-message for="pb-book" locale="en" up="18" down="0"
value='{"template":"was","slots":{"thing":"stitching","quality":"flawless"},"conjunction":"but","next":{"template":"check","slots":{"thing":"fit"}}}'></mv-phrasebook-message>
</div>
</li>
<li class="pb-item">
<span class="pb-av" aria-hidden="true">LR</span>
<div class="pb-body">
<span class="pb-who"><strong>Lucía Romero</strong><span>Valencia · Sep 4, 2026</span></span>
<mv-phrasebook-message for="pb-book" locale="en" up="7" down="3" vote="down"
value='{"template":"was","slots":{"thing":"tracking","quality":"disappointing"},"conjunction":"so","next":{"template":"helps","slots":{"action":"ask-first"}}}'></mv-phrasebook-message>
</div>
</li>
</ul>
</section>
<script type="module">
const book = document.getElementById("pb-book");
const form = document.getElementById("pb-form");
const json = document.getElementById("pb-json");
const log = document.getElementById("pb-log");
const feed = document.getElementById("pb-feed");
const readAs = document.getElementById("pb-readas");
const count = document.getElementById("pb-count");
const previews = ["pb-en", "pb-es", "pb-ja"].map((id) => document.getElementById(id));
const show = () => {
const value = book.value;
for (const el of previews) el.value = value;
json.textContent = value ? JSON.stringify(value, null, 2) : "null";
};
customElements.whenDefined("mv-phrasebook").then(() => requestAnimationFrame(show));
book.addEventListener("mv-change", (e) => {
show();
log.textContent = e.detail.complete ? `Ready: “${e.detail.text}”` : `Draft (${e.detail.reason})`;
});
// "reset" fires before the fields are restored: allow two phrases again so the default message comes back whole.
form.addEventListener("reset", () => { book.maxClauses = 2; requestAnimationFrame(show); log.textContent = "Reset to the saved draft."; });
// The app can veto a pick: here, "plus" cannot follow a negative remark.
book.addEventListener("mv-pick", (e) => {
const v = book.value;
if (e.detail.kind === "conjunction" && e.detail.id === "plus" && ["disappointing", "below-average"].includes(v?.slots?.quality)) {
e.preventDefault();
log.textContent = "Blocked: “plus” can't follow a negative remark.";
}
});
document.getElementById("pb-two").addEventListener("change", (e) => {
book.maxClauses = e.target.checked ? 2 : 1;
show();
});
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!book.checkValidity()) { book.reportValidity(); return; }
const li = document.createElement("li");
li.className = "pb-item";
li.dataset.new = "";
const av = Object.assign(document.createElement("span"), { className: "pb-av", textContent: "You" });
av.setAttribute("aria-hidden", "true");
const body = Object.assign(document.createElement("div"), { className: "pb-body" });
const who = Object.assign(document.createElement("span"), { className: "pb-who" });
who.append(Object.assign(document.createElement("strong"), { textContent: "You" }),
Object.assign(document.createElement("span"), { textContent: `Just now · ${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}` }));
const msg = book.render(book.value, { locale: readAs.value, rating: "votes", up: 0, down: 0 });
body.append(who, msg);
li.append(av, body);
feed.prepend(li);
count.textContent = `${feed.children.length} messages`;
log.textContent = "Posted. Readers see it in their language.";
book.clear();
show();
});
readAs.addEventListener("mv-change", (e) => {
for (const m of feed.querySelectorAll("mv-phrasebook-message")) m.setAttribute("locale", e.detail.value);
});
// Votes: optimistic, confirmed by a fake API round trip.
feed.addEventListener("mv-rate", (e) => {
e.detail.waitUntil(new Promise((resolve) => setTimeout(resolve, 350)));
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
name | string | Form field name. The submitted value is the structured message as JSON (nothing when empty). | |
value | JSON string | Initial message, e.g. '{"template":"helps","slots":{"action":"size-up"}}'. Restored by a form reset. | |
required | boolean | An empty composer is invalid (valueMissing). A started but incomplete message is always invalid (badInput), required or not. | |
disabled | boolean | Disables every piece (also via <fieldset disabled>). | |
label | string | Accessible name of the sentence group when no <label for> points at the element. | |
locale | BCP 47 tag | Language the composer displays words and templates in (falls back from "es-MX" to "es", then to the source labels). The stored value never changes with it. | |
source-locale | BCP 47 tag | en | Language of the declared labels (used for the preview's language chips, glosses and capitalization rules). |
max-clauses | number | 2 | How many phrases can be chained with connectors. 1 hides “Add a second phrase”; lowering it trims extra phrases. |
placement | bottom-start | bottom | top-start … | bottom-start | Preferred side of the word picker relative to the piece that opened it (flips and shifts to stay in the viewport). |
data-variant | "plain" | Drops the card frame (border, background, padding) to embed the composer in your own container. | |
data-empty / data-complete / data-open | state | Set by the component: nothing chosen yet, every blank filled, a picker is open. |
Properties
| Name | 유형 | Description |
|---|---|---|
value | { template, slots: Record<string, string>, conjunction?, next? } | null | The structured message: template id, word id per blank name, and an optional chained phrase. Accepts an object or a JSON string; null or "" clears. Unknown ids are kept and shown as blanks until the vocabulary knows them. |
text | string | The message rendered as plain text in the current locale (read-only). |
complete | boolean | True when every template, blank and connector is filled (read-only). |
templates | Array<{ id, text, group?, keepCase?, keywords? }> | Templates. Blanks are written {name}, or {name:type} / {name:type|other} when the name differs from the accepted word types (two blanks of the same type need two names). Replaces the <option data-template> children. |
words | Array<{ id, label, type, group?, keywords? }> | Vocabulary. type decides which blanks accept the word, group is the picker category. Labels are written as they appear mid-sentence (“the fabric”); the first letter of a message is capitalized automatically. |
conjunctions | Array<{ id, label }> | Connectors that join phrases. None means a single phrase. |
types | Record<string, string> | Human label of each word type, shown in empty blanks and in the picker (“aspect”, “tip”). Defaults to the type id. |
messages | Record<locale, { templates?, words?, conjunctions?, types?, groups?, format?: { join, end } }> | Dictionaries keyed by locale. Templates reorder blanks by name (“{thing}は{quality}でした”); format.join places the connector (default "{first}, {conjunction} {second}") and format.end the final punctuation (default "."). Missing entries fall back to the source labels and are reported as untranslated. |
locales | string[] | Source locale followed by every dictionary key (read-only). |
book | object | The normalized phrasebook (read-only), accepted by <mv-phrasebook-message>.source and formatPhrase(). |
strings | Partial<Record<string, string>> | Overrides for the component's own UI text (choosePhrase, addClause, preview, readsIn, clear, searchWords ({type}), noMatch ({query}), blanksLeft ({n}), ready, valueMissing, incomplete…). English defaults. |
Methods
| Name | Description |
|---|---|
format(value?, locale?) | Plain-text rendering of a message (defaults: current value and locale). |
render(value?, { locale?, rating?, up?, down?, vote? }) | Returns a new <mv-phrasebook-message> bound to this phrasebook, ready to insert in a feed. |
open({ clause?, slot?, part? }) | Opens the picker of a piece (part: "template" | "conjunction"); with no argument, the first unfilled piece. |
close() / clear() / focus() | Closes the picker; resets to an empty composer (emits mv-change with reason "clear"); focuses the first unfilled piece. |
checkValidity() / reportValidity() | Native constraint validation; the validation bubble points at the first unfilled piece. |
formatPhrase(value, book, { locale, sourceLocale }) (module export) | Pure, SSR-safe text rendering from a plain phrasebook object; phraseParts() returns the parts, completeness and untranslated ids. |
Events
| Name | Description |
|---|---|
mv-pick | Cancelable, before a choice is applied. detail: { kind: "template" | "word" | "conjunction", id, clause, slot }. preventDefault() refuses it (the picker stays open and says why), e.g. to block a combination. |
mv-change | The message changed through the UI. detail: { value, text, complete, reason: "template" | "word" | "conjunction" | "add" | "remove" | "clear" | "clear-piece", clause?, slot?, id? }. |
mv-open / mv-close | The word picker opened (detail: { kind, clause, slot }) or closed. |
mv-vocabulary | The phrasebook (re)loaded its vocabulary. detail: { book }. Bound <mv-phrasebook-message> elements repaint on it. |
mv-rate (on mv-phrasebook-message) | Cancelable, before a vote is applied. detail: { vote: "up" | "down" | null (vote removed), previous, up, down, value, waitUntil(promise) }. The counts update at once; a rejected waitUntil promise rolls them back and emits mv-rate-error { vote, previous, error }. |
mv-rate-error (on mv-phrasebook-message) | A waitUntil promise passed in mv-rate was rejected: the vote and counts are rolled back. detail: { vote, previous, error }. |
Content structure
| Name | Description |
|---|---|
(children <option data-template>) | <option data-template="was" data-group="Describe">{thing} was {quality}</option>; data-keep-case keeps its first letter when it follows a connector. |
(children <option data-word> / <optgroup data-word>) | Words: <optgroup label="Shipping" data-word="thing"><option value="delivery">delivery</option></optgroup> (the optgroup label is the category; value is the id, defaulting to the slugified text). data-keywords adds search synonyms. |
(children <option data-conjunction> / <option data-type>) | Connectors (<option data-conjunction="but">but</option>) and type labels (<option data-type="thing">aspect</option>). |
(child <script type="application/json" data-phrasebook>) | The same data as JSON: { templates, words, conjunctions, types, messages, format }. Children stay in the DOM (hidden) and are re-read when they change. |
CSS classes
| Name | Description |
|---|---|
mv-phrasebook-clause / -line / -text | One phrase row (role=group), its sentence line and the template's literal words. |
mv-phrasebook-piece | Every interactive part of the sentence (data-kind="template | slot | conjunction", data-empty, data-open, data-fresh right after a pick): -template (switcher, or -start when the phrase is empty), -slot, -conjunction; -join is the connector rail, -remove and -add manage phrases. |
mv-phrasebook-preview | Live preview: -preview-text (-preview-word, -preview-conjunction, -preview-blank), -progress, -locales / -locale (data-partial), -clear. |
mv-phrasebook-popup | Word picker (Popover API, data-kind): -search, -search-input (role=combobox), -filters / -filter (aria-pressed), -list (role=listbox), -group, -option (data-active, aria-selected), -option-gloss, -option-blank, -match, -empty, -hint. |
mv-phrasebook-message | Read view element <mv-phrasebook-message for locale value up down vote rating>: -message-text (-message-word, -message-conjunction), -rating (role=group), -vote (data-vote, aria-pressed), -count. rating="votes" (default), "readonly" or "none"; data-variant="plain" drops the frame. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-phrasebook-word | var(--mv-accent) | Tint of filled blanks, focus of empty ones and the start icon. |
--mv-phrasebook-connector | var(--mv-fg-muted) | Text color of connector pills. |
--mv-phrasebook-font-size | 1.0625rem | Size of the sentence line in the composer. |
--mv-phrasebook-list-height | 15rem | Maximum height of the picker list. |
--mv-phrasebook-helpful | var(--mv-accent) | Tint of a pressed “helpful” vote. |
--mv-phrasebook-message-size | var(--mv-text-sm) | Text size of the read view. |
Accessibility
The sentence is a labelled group (from <label for>, label or aria-label) of one group per phrase; every blank, connector and template switch is a native <button> with aria-haspopup="listbox", aria-expanded and a name that states what it holds (“Aspect: delivery”, “Aspect: empty”, “Connector: but”, “Change phrase (____ was ____)”), so the visible word is always part of the accessible name. Tab walks the sentence in reading order; Left/Right (mirrored in RTL), Home and End move between pieces; Enter, Space or Down opens a piece, typing a letter opens it with that letter searched, Backspace/Delete empties it (on a later phrase's switcher, removes the phrase). The picker is a labelled dialog around a real combobox input (aria-controls, aria-activedescendant) and a grouped listbox with aria-selected: Up/Down move, Page Up/Down jump between categories, Left/Right switch the category filter while the search is empty, Enter picks, Escape or Alt+Up close and return focus to the piece, Tab closes and moves on. Picking moves focus to the next empty blank of the same phrase (and opens it) or back to the piece, and a polite live region announces the word and the whole sentence; result counts and refused picks are announced inside the picker. Rendered sentences carry the lang of their locale so screen readers switch pronunciation; blanks read as “blank aspect”. Validity uses ElementInternals: required gives valueMissing, a started message with blanks gives badInput with “Fill in every blank before sending (1 blank left)”, anchored to the first empty piece. In <mv-phrasebook-message>, the votes are toggle buttons (aria-pressed) with visible counts and hidden “Helpful” / “Not helpful” names inside a labelled group, aria-busy while a waitUntil promise runs. Colors always come with shape: empty blanks are dashed and italic, partial translations have a dashed chip and a dot, votes fill their icon. Reduced motion (OS or data-motion="reduce"): no pop, no row entrance, counts and the picker change instantly. Forced colors: pieces get ButtonText borders, the active option a Highlight outline, pressed votes Highlight.