コンポーネントは自分の言葉を持ちません。 どのコンポーネントも、どの言語でも、既定のテキストを持ちません。「必須」も「閉じる」も「OK」もありません。コンポーネントが描いたり読み上げたりする言葉は、プロダクトが渡したものです。自分で書いたものか、自分で import した英語の言葉のパックから取ったものです。
既定値は、それが引き起こす失敗を隠します。日本語のプロダクトが言葉を 1 つ書き忘れると、自分のテキストの横に英語が出ますが、そのことは誰にも報告されません。英語のプロダクトにも、自分の言い回しがあります(「Mandatory」「Done」「Nothing found」)。そこで、コンポーネントは代わりに使う言葉を持たず、足りない言葉があればそれを知らせます。英語の言葉のパックは、そのような代わりの言葉ではありません。プロダクト自身が import しない限り、使われません。
コンポーネントは、次の 3 か所をこの順に見ます。ほかの場所は見ません。
| 場所 |
渡すもの |
例 |
| コンポーネント自身 |
その 1 つだけについての言葉と、その 1 つだけ言い方を変えたい言葉 |
label、error-text、close-label="絞り込みを閉じる" |
| プロダクトの言葉 |
そのパーツがどこに描かれても同じ言い方をする言葉。言語ごとに一度だけ渡すか、import した英語の言葉のパックから取ります |
requiredText、closeLabel、previousMonthLabel |
| プラットフォーム |
ブラウザーが読み手の言語ですでに持っているもの |
月名、曜日名、数字、「午前」、値がないときのブラウザー自身のメッセージ |
この 3 つのあとには何もありません。代わりの言語はなく、「とりあえず英語」もありません。
Tanrei は、プロダクトの言葉を 1 組だけ用意しています。すべてのキーに、平易で癖のない英語の言葉を 1 つずつ(「Required」「Close」「Previous month」など)。一度だけ import します。
import '@tanrei/web/words/en';
これで、setWords() を 1 回呼んだときと同じように、ページ全体の言葉が渡ります。プロダクトの代わりにこれを import するものはありません。@tanrei/web も @tanrei/react も、コンポーネントも、ページの lang も import しません。キーごとのパックの言葉は、キーの一覧にあります。
- 自分の言葉が優先します。 import のあとに
setWords() を呼ぶと、渡したキーはパックの言葉と置き換わります。コンポーネントに書いた言葉は、その両方に優先します。import は実行されたときにすべてのキーを渡すので、それより前に渡した言葉は置き換えられます。
- パックの言葉から始められます。 モジュールは同じ言葉を値としても出しています。
import { englishWords } from '@tanrei/web/words/en'
- 英語以外のプロダクトでは、その言語の組にないキーはページの組から読まれます。パックを import していれば、それは英語です。自分で書いていないキーに英語を出してよいところでだけ import してください。import しなければ、足りない言葉は報告されます。
- 隠すべきでないものは隠しません。 パックを import すると、すべてのキーに言葉があるので、
missingWords() は空になります。プロダクトが自分で書いていない言葉を探すには、パックを import しないテストで確かめます。
React でも、import は同じ 1 行です。最初に描画するモジュールに書きます。
Web では、setWords() を一度呼びます。コンポーネントを読み込む前でも後でもかまいません。すでにページにある要素は、すぐにその言葉を描きます。
import { setWords } from '@tanrei/web';
setWords({
requiredText: '必須',
optionalText: '任意',
closeLabel: '閉じる',
clearLabel: '消去',
targetLabel: '新しいタブで開きます',
});
渡すのは、使うコンポーネントの言葉だけでかまいません。2 回目の呼び出しは、1 回目にキーごとに加わります。空の文字列を渡したキーは取り除かれます。@tanrei/web/words は、要素を定義せずに同じ関数だけを提供します。@tanrei/react からも使えます。
コンポーネントに書いた属性は、プロダクトの言葉より常に優先されます。
format_textdirection_r_to_lcode
<tanrei-text-field label="メールアドレス" required required-text="必須"></tanrei-text-field>
<tanrei-text-field label="会社名" required required-text="請求書に必要"></tanrei-text-field>
言語ごとに 1 組ずつ渡します。コンポーネントは、自分を囲むいちばん近い lang 属性の組を読みます。日本語のページでも、英語のページの中の日本語の部分でも、日本語の言葉が読まれます。
setWords({ requiredText: 'Required', closeLabel: 'Close' }, { lang: 'en' });
setWords({ requiredText: '必須', closeLabel: '閉じる' }, { lang: 'ja' });
ja の組は ja-JP にも使われます。ja-JP に専用の組を渡した場合は、そちらが使われます。
- その言語の組にないキーは、
lang なしで渡した組から読みます。
- 言葉はただの文字列です。プロダクトにローカライズのライブラリがあるなら、そこから文字列を渡します。
setWords() は翻訳の仕組みではありません。複数形の規則はありません。いくつかの言葉には、コンポーネントが埋める場所があります。言葉の中で読まれるのは、それだけです。
文の中には、コンポーネントだけが、その文を言う瞬間に知っている値を含むものがあります。移動のあとに行が何番目になったか、貼り付けでいくつの項目ができたか、などです。その場合も、文全体を書くのはプロダクトです。値の入る場所に、波かっこで囲んだ名前で印を付けます。コンポーネントは、そこに値を入れます。
setWords({ movedText: '{headline}: {position} of {count}' }, { lang: 'en' });
setWords({ movedText: '{headline}:{count} 件中 {position} 番目' }, { lang: 'ja' });
語順は、プロダクトの言語のものです。コンポーネントは場所を埋めるだけで、文にはほかに何もしません。
- 名前を決めるのはコンポーネントです。 キーごとに場所が決まっていて、キーの一覧に載っています。埋められるのは、その場所だけです。1 つの場所は、1 回書いても、何回書いても、書かなくてもかまいません。
- 数は、要素の言語の書き方で書きます。
{count} と {position} は整数です。いちばん近い lang の数字と桁区切りで書きます。
- テキストは、自分の方向を保ちます。
{headline} と {entry} は、コンポーネントが持っているテキストです。行の見出しや、利用者が入力した項目です。マークアップとしては扱わず、そのままのテキストとして入れます。文を描くときと読み上げるときには、そのテキストを周りから切り離します。アラビア語の文に入ったラテン文字の名前は、元の並びのままです。
- 波かっこそのものは、2 つ続けて書きます。
{{ は {、}} は } として描きます。場所のない言葉は読まれないので、そこでは波かっこを 1 つだけ書きます。
- 必要な場所が 1 つあります。
entryRemoveLabel は、チップフィールドの項目ごとの削除ボタンの名前です。{entry} がないと、すべてのボタンが同じ名前になります。
開発時には、そのキーにない場所({entri})、対になっていない波かっこ、足りない {entry} を、コンソールに報告します。その場合も、文は書かれたとおりに使います。setWords() は言葉を渡したときに報告します。コンポーネントは、要素に書いた言葉を使うときに報告します。
コンポーネントには文法がありません。複数形も、序数も、2 つの文からの選び分けもありません。数の扱い方は 3 つあります。どれも、Tanrei には何も求めません。
- どの数でも正しい文を書きます。
{count} 件中 {position} 番目、追加:{count} 件 などです。
- 言葉をコンポーネントに書き、件数が変わったら書き換えます。件数はプロダクトが知っていて、コンポーネントに書いた言葉が常に優先されます。どの文にするかは、プロダクト自身の複数形の規則で決めます。
- 数をプロダクト自身が持っている場合、その言葉に場所はありません。テーブルのステータス(「132 件中 1〜20 件」)は、プロダクトが書く 1 つの文字列です。
言葉が足りないのは、ラベルのないテキストフィールドと同じく、プロダクトの不具合です。コンポーネントは、利用者への影響を小さくし、開発者にはそれが見えるようにします。
- 描くはずだった言葉は描きません。 同じことを表せる印があれば、その印を代わりに置きます。必須のタグにはアスタリスク、ピッカーのキャンセルと OK には閉じるアイコンとチェックのアイコン、「翌日」には
+1 です。そのような印がなければ何も描かず、その場所の大きさは保ちます。
- 名前の必要なコントロールは、名前がなくても描かれ、動作します。 カレンダーのナビゲーションボタン、テキストフィールドのサイズ変更のハンドル、ピッカーのアクションは、名前がなくても動作します。例外は数値フィールドのステッパーだけです。名前のないステッパーは支援技術から隠します。隣のスピンボタンが自分で増減するからです。
- 報告します。 開発時には、コンソールに属性の名前が出ます。1 つの言葉につき、ページごとに 1 回です。
<tanrei-field>: required-text is empty and the product's words have no requiredText
アクセシブルネームのないコントロールは、スクリーンリーダーの利用者には使えません。報告は直すためのもので、そのままにしてよいという意味ではありません。
missingWords() は、ページを読み込んでから必要になり、誰も渡さなかったキーを返します。プロダクトの画面をひととおり表示するテストの最後に確かめます。英語の言葉のパックを import したページでは、足りない言葉はありません。
import { missingWords } from '@tanrei/web';
test('プロダクトに必要な言葉がすべて渡されている', async () => {
// …プロダクトの画面を描画する…
expect(missingWords()).toEqual([]);
});
グリフ、アスタリスク、ダッシュ、数字、ロケールが決める区切りは言葉ではないので、コンポーネントはそのまま描きます。API の名前(プロパティや値)と、コンソールが開発者に伝える内容も言葉ではありません。後者は英語です。キーボードショートカットが描く文字(「Ctrl」「Enter」「⌘」)は、キーそのものに書かれているものです。ショートカットとして読み上げられるのは、渡した label です。
すべてのキーと、それを読み取るプロパティ、コンポーネントがその文の中で埋める場所、そして英語の言葉のパックでの言葉です。ほとんどのキーは、プロパティと同じ名前です。違うのは 3 つです。コピーボタンの label と failedLabel は copyLabel と copyFailedLabel を読み、パンくずリストの overflowLabel は breadcrumbsOverflowLabel を読みます。
| キー | 読み取るプロパティ | 埋める場所 | 英語の言葉のパック |
|---|
addedLabel | code-block.addedLabel | | Added |
|---|
attachLabel | chat-composer.attachLabel | | Attach files |
|---|
avatarGroupOverflowLabel | avatar.overflowLabel | {count} | {count} more |
|---|
backLabel | search.backLabel | | Back |
|---|
breadcrumbsOverflowLabel | breadcrumbs.overflowLabel | | Show hidden levels |
|---|
busyLabel | button.busyLabel | | Loading |
|---|
cancelLabel | date-time-picker.cancelLabel | | Cancel |
|---|
cancelledText | file-upload.cancelledText, list.cancelledText | {label}, {position}, {count} | {label} returned to {position} of {count} |
|---|
characterCountNoLimitText | text-input.counterNoLimitText | {count} | Characters: {count} |
|---|
characterCountText | text-input.counterText | {count}, {limit}, {remaining} | Characters: {count} of {limit} |
|---|
clearLabel | combobox.clearLabel, navigation-drawer.filterClearLabel, search.clearLabel, tree-view.filterClearLabel | | Clear |
|---|
closeLabel | bottom-sheet.closeLabel, callout.closeLabel, image-preview.closeLabel, popover.closeLabel, side-sheet.closeLabel, snackbar.closeLabel | | Close |
|---|
confirmLabel | date-time-picker.confirmLabel | | OK |
|---|
copiedLabel | code-block.copiedLabel, copy-button.copiedLabel | | Copied |
|---|
copyFailedLabel | code-block.copyFailedLabel, copy-button.failedLabel | | Could not copy |
|---|
copyLabel | code-block.copyLabel, copy-button.label | | Copy |
|---|
dateLabel | date-time-picker.dateLabel | | Date |
|---|
decrementLabel | number-input.decrementLabel | | Decrease |
|---|
dialLabel | date-time-picker.dialLabel | | Dial |
|---|
dragHandleHint | file-upload.dragHandleHint, list.dragHandleHint | | Press Space to lift, the arrow keys to move, Space to drop, Escape to cancel; Enter opens the moves |
|---|
dragHandleLabel | file-upload.dragHandleLabel, list.dragHandleLabel | {label} | Reorder {label} |
|---|
dropZoneRefusingText | drop-zone.refusingText | | These files cannot be added here |
|---|
dropZoneText | drop-zone.dropText | | Drop files here |
|---|
duplicateEntryText | chip-field.duplicateText | | This is already in the list. |
|---|
editLabel | tree-view.editLabel | | Name |
|---|
emptyText | combobox.emptyText | | No matches |
|---|
endLabel | calendar.endLabel, date-time-picker.endLabel | | End |
|---|
entriesAddedText | chip-field.addedCountText | {count} | Added: {count} |
|---|
entryAddedText | chip-field.addedText | {entry} | {entry} added |
|---|
entryLimitText | chip-field.limitText | | No more can be added. |
|---|
entryModeLabel | date-time-picker.entryModeLabel | | Input mode |
|---|
entryRemoveLabel | chip-field.removeLabel | {entry} | Remove {entry} |
|---|
entryRemovedText | chip-field.removedText | {entry} | {entry} removed |
|---|
expandLabel | snackbar.expandLabel | | Show full message |
|---|
externalLabel | link.externalLabel, link-card.externalLabel | | external link |
|---|
fileCountErrorText | chat-composer.countErrorText, file-upload.countErrorText | | No more files can be added. |
|---|
fileSizeErrorText | chat-composer.sizeErrorText, file-upload.sizeErrorText | | This file is too large. |
|---|
fileTypeErrorText | chat-composer.typeErrorText, file-upload.typeErrorText | | This kind of file cannot be added. |
|---|
filesDroppedText | drop-zone.droppedText | {count} | Files added: {count} |
|---|
filesRefusedText | drop-zone.refusedText | {count} | Files not added: {count} |
|---|
filterCountText | navigation-drawer.filterCountText, tree-view.filterCountText | {count} | Matches: {count} |
|---|
filterEmptyText | navigation-drawer.filterEmptyText, tree-view.filterEmptyText | | No matches |
|---|
filterPlaceholder | navigation-drawer.filterPlaceholder, tree-view.filterPlaceholder | | Filter |
|---|
firstPageLabel | pagination.firstLabel | | First page |
|---|
handleLabel | bottom-sheet.handleLabel | | Resize sheet |
|---|
highlightedLabel | code-block.highlightedLabel | | Highlighted |
|---|
hourLabel | date-time-picker.hourLabel | | Hour |
|---|
imageCountText | image-preview.countText | {position}, {count} | {position} of {count} |
|---|
imageErrorText | image-preview.errorText | | This image could not be loaded |
|---|
incrementLabel | number-input.incrementLabel | | Increase |
|---|
lastPageLabel | pagination.lastLabel | | Last page |
|---|
liftedText | file-upload.liftedText, list.liftedText | {label}, {position}, {count} | {label} lifted: {position} of {count} |
|---|
loadingLabel | image-preview.loadingLabel, navigation-drawer.loadingLabel, skeleton.label, tree-view.loadingLabel | | Loading |
|---|
marqueePauseLabel | marquee-text.pauseLabel | | Pause scrolling text |
|---|
marqueePlayLabel | marquee-text.playLabel | | Play scrolling text |
|---|
menubarOverflowLabel | menubar.overflowLabel | | More |
|---|
minuteLabel | date-time-picker.minuteLabel | | Minute |
|---|
moveAfterLabel | file-upload.moveAfterLabel, list.moveAfterLabel | | Move down |
|---|
moveBeforeLabel | file-upload.moveBeforeLabel, list.moveBeforeLabel | | Move up |
|---|
moveToEndLabel | file-upload.moveToEndLabel, list.moveToEndLabel | | Move to bottom |
|---|
moveToStartLabel | file-upload.moveToStartLabel, list.moveToStartLabel | | Move to top |
|---|
movedText | file-upload.movedText, list.movedText | {label}, {position}, {count} | {label}: {position} of {count} |
|---|
nextDayLabel | date-time-picker.nextDayLabel | | next day |
|---|
nextImageLabel | image-preview.nextLabel | | Next image |
|---|
nextItemLabel | carousel.nextLabel | | Next slide |
|---|
nextMonthLabel | calendar.nextMonthLabel, date-time-picker.nextMonthLabel | | Next month |
|---|
nextPageLabel | pagination.nextLabel | | Next page |
|---|
noFileText | file-upload.emptyText | | No file chosen |
|---|
noOptionsText | select.emptyText | | No options |
|---|
optionalText | field.optionalText, field-group.optionalText | | Optional |
|---|
pageCountText | pagination.countText | {page}, {count} | Page {page} of {count} |
|---|
pageLabel | pagination.pageLabel | {page} | Page {page} |
|---|
pauseLabel | carousel.pauseLabel | | Pause slideshow |
|---|
periodLabel | date-time-picker.periodLabel | | AM/PM |
|---|
playLabel | carousel.playLabel | | Play slideshow |
|---|
previousImageLabel | image-preview.previousLabel | | Previous image |
|---|
previousItemLabel | carousel.previousLabel | | Previous slide |
|---|
previousMonthLabel | calendar.previousMonthLabel, date-time-picker.previousMonthLabel | | Previous month |
|---|
previousPageLabel | pagination.previousLabel | | Previous page |
|---|
rangeErrorText | date-time-picker.rangeErrorText | | End must be after start |
|---|
rangeLabel | date-time-picker.rangeLabel | | Range |
|---|
removeLabel | file-item.removeLabel | {name} | Remove {name} |
|---|
removedLabel | code-block.removedLabel | | Removed |
|---|
requiredText | field.requiredText, field-group.requiredText | | Required |
|---|
resizeLabel | text-input.resizeLabel | | Resize |
|---|
retryLabel | file-item.retryLabel | {name} | Retry {name} |
|---|
segmentEmptyText | text-input.segmentEmptyText | | Empty |
|---|
selectAllLabel | table.selectAllLabel | | Select all rows |
|---|
sendLabel | chat-composer.sendLabel | | Send |
|---|
sortedAscendingLabel | table.sortedAscendingLabel | | sorted ascending |
|---|
sortedDescendingLabel | table.sortedDescendingLabel | | sorted descending |
|---|
startLabel | calendar.startLabel, date-time-picker.startLabel | | Start |
|---|
stepCompletedText | stepper.completedText | | Completed |
|---|
stepErrorText | stepper.errorText | | Has an error |
|---|
stepPositionText | stepper.positionText | {position}, {count} | Step {position} of {count} |
|---|
stopLabel | chat-composer.stopLabel | | Stop |
|---|
targetLabel | button.targetLabel, card.targetLabel, chip.targetLabel, link.targetLabel, link-card.targetLabel, list/list-item.targetLabel, menu/item.targetLabel, menubar/item.targetLabel, navigation-bar/destination.targetLabel, navigation-drawer/destination.targetLabel, search/result.targetLabel, search/result-group.targetLabel | | opens in a new tab |
|---|
todayLabel | calendar.todayLabel, date-time-picker.todayLabel | | Today |
|---|
toolbarOverflowLabel | toolbar.overflowLabel | | More actions |
|---|
wheelsLabel | date-time-picker.wheelsLabel | | Wheels |
|---|
yearViewLabel | calendar.yearViewLabel, date-time-picker.yearViewLabel | | Choose a year |
|---|
zoomInLabel | image-preview.zoomInLabel | | Zoom in |
|---|
zoomOutLabel | image-preview.zoomOutLabel | | Zoom out |
|---|
1 つのものだけについての言葉には、キーがありません。フィールドの label、日付と時刻のピッカーの label、削除ボタンの「山田 花子を削除」、件数を含むステータステキストなどです。これらはコンポーネントに書きます。
この規則は、どのプラットフォームでも同じです。いま実装されているのは Web だけです。SwiftUI、Jetpack Compose、Flutter、React Native は、それぞれの流儀でプロダクトの言葉と、同じ英語の言葉のパックを受け取るようになります。