copy.js: a snippet's Copy button, hidden where there is no clipboard, in twelve languages
The site's install line has a Copy button of its own in site.js; any page with a line of code to copy needs the same, so it moves into Fiesta as an opt-in file, like busy.js and anchor.js, rather than into fiesta.js, which every page loads. ScriptNames lists it after shell.js. The button is hidden in the markup and only copy.js shows it, and where navigator.clipboard.writeText is not a function the file returns before it listens for anything. With no script, or no clipboard, there is no button that does nothing. A copy that fails, by a rejected promise or a throw, selects the code so it can be copied by hand. Each click is a numbered request, and only a button's latest may act: a click puts the label back, empties the status and cancels both of the button's timers before it writes, and every settlement and timer callback checks the number again. Without that, a success followed by a failure left Copied on the label, and two writes settling out of order ended on the first. The status is written 100 ms after the write settles, never at once: a live region written the text it already holds has not changed, and a screen reader says nothing for a second copy. Rapid clicks, or a click while a write is pending, end in one announcement, the latest's. Its strings are copy-done and copy-failed, new keys in all twelve i18n/<lang>.json, read as select.js reads its own: the attribute, the page's table, then the English in the script. copy.js carries fiestaPickLang and fiestaWord byte for byte as select.js does. The English is the gallery's approved gallery.copied and gallery.copy.failed; every other language's value is carried from the same two keys in that locale's gallery catalog, not drafted, so the two never disagree. Getting started lists copy.js after shell.js, with the approved gallery.started.file.copy-js, and takes the approved change to gallery.started.file.strings. Both strings' translations into the eleven gallery locales are machine drafts from the approved English, not reviewed, and drafts.json records them. New-behaviour tests, each seen red by assertion against a copy.js that was only its header comment: TestCopyContract, TestCopyCopiesAndSaysSo, TestAClassSpelledSnippetCopies, TestASuccessThenAFailureEndsOnTheFailure, TestWritesThatSettleOutOfOrderEndOnTheLast, TestTheStatusIsALiveRegionBeforeItSpeaks, TestACopyThatFailsSelectsTheCode, TestCopyStaysHiddenWithoutAClipboard and TestCopyReadsTheTable. Two legs pass against the stub, since they hold the markup rather than the script, and are labelled already passing: the scripts-off leg of TestCopyStaysHiddenWithoutAClipboard turns red with hidden dropped from the button, and the accessibility-tree leg of TestTheStatusIsALiveRegionBeforeItSpeaks with hidden put on the status. Adapted preservation tests: TestEveryScriptIsEmbedded's list and Getting started's shippedRows gain copy.js; TestScriptsAreSelfContained counts 9 scripts, not 8; TestEveryLanguageHasEveryKey holds 23 keys, not 21; TestTheReleaseScriptsDriveTheJourneys gains one journey, driveCopy over the release's stripped copy.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
34 files changed,
+918
−37
check/copy_browser_test.go+608 −0check/release_browser_test.go+9 −3check/scripts_test.go+44 −2check/strings_test.go+6 −5fiesta.go+10 −9fiesta_test.go+1 −1i18n/ar.json+2 −0i18n/bn.json+2 −0i18n/en.json+2 −0i18n/es.json+2 −0i18n/ga.json+2 −0i18n/hi.json+2 −0i18n/ja.json+2 −0i18n/pt-PT.json+2 −0i18n/ru.json+2 −0i18n/vi.json+2 −0i18n/yue.json+2 −0i18n/zh-Hans.json+2 −0internal/gallery/copy.go+2 −1internal/gallery/i18n/ar.json+2 −1internal/gallery/i18n/bn.json+2 −1internal/gallery/i18n/drafts.json+17 −1internal/gallery/i18n/es.json+2 −1internal/gallery/i18n/ga.json+2 −1internal/gallery/i18n/hi.json+2 −1internal/gallery/i18n/ja.json+2 −1internal/gallery/i18n/pt-PT.json+2 −1internal/gallery/i18n/ru.json+2 −1internal/gallery/i18n/vi.json+2 −1internal/gallery/i18n/yue.json+2 −1internal/gallery/i18n/zh-Hans.json+2 −1internal/gallery/started.go+3 −3internal/gallery/started_test.go+1 −1js/copy.js+171 −0
Intent
diff --git a/check/copy_browser_test.go b/check/copy_browser_test.go| new file mode 100644 |
| index 0000000..56845c9 |
| --- /dev/null |
| +++ b/check/copy_browser_test.go |
| @@ -0,0 +1,608 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/cdproto/accessibility" |
| + "github.com/chromedp/cdproto/browser" |
| + "github.com/chromedp/cdproto/cdp" |
| + "github.com/chromedp/cdproto/emulation" |
| + cdppage "github.com/chromedp/cdproto/page" |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// The snippet's Copy button, driven in a browser. What can go wrong is |
| +// a button that does nothing, a message nobody hears, and a message |
| +// that is out of date: so the button stays hidden where copy.js cannot |
| +// work, a failure selects the code, every message goes through a live |
| +// region emptied first, and only the latest click's write may speak. |
| + |
| +// snippetMarkup is the spec's snippet, attribute spelling, with the |
| +// optional strings on the button as the gallery writes them. |
| +const snippetMarkup = `<div rst-snippet> |
| + <code dir="ltr" translate="no"><link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"></code> |
| + <button rst-btn="sm" type="button" rst-snippet-copy hidden data-rst-copy-done="Copied" data-rst-copy-failed="Copy failed. Select the code and copy it yourself.">Copy</button> |
| + <span rst-snippet-status role="status"></span> |
| +</div>` |
| + |
| +// classSnippet is the class spelling with no strings on the button, |
| +// so what it says comes from the English written in copy.js. |
| +const classSnippet = `<div class="rst-snippet"> |
| + <code dir="ltr" translate="no"><link rel="stylesheet" href="https://fiesta.rastrillo.org/1/fiesta.css"></code> |
| + <button rst-btn="sm" type="button" class="rst-snippet__copy" hidden>Copy</button> |
| + <span class="rst-snippet__status" role="status"></span> |
| +</div>` |
| + |
| +// tableSnippet has no strings on the button, so a page's table speaks; |
| +// done, when set, is the button's own done string, which wins. |
| +func tableSnippet(done string) string { |
| + attr := "" |
| + if done != "" { |
| + attr = ` data-rst-copy-done="` + done + `"` |
| + } |
| + return `<div rst-snippet> |
| + <code dir="ltr" translate="no">fiesta.css</code> |
| + <button rst-btn="sm" type="button" rst-snippet-copy hidden` + attr + `>Copy</button> |
| + <span rst-snippet-status role="status"></span> |
| +</div>` |
| +} |
| + |
| +// snippetDoc is sizingDoc's page in lang, with body and then the |
| +// scripts tags: copy.js is opt-in, so sizingDoc does not load it. |
| +func snippetDoc(lang, body, scripts string) string { |
| + return strings.Replace(sizingDoc("snippet", body+scripts), `<html lang="en"`, `<html lang="`+lang+`"`, 1) |
| +} |
| + |
| +const copyTag = `<script defer src="/copy.js"></script>` |
| + |
| +// copyPages are the snippet pages: the spec's markup at /, the class |
| +// spelling at /class, and Irish with its table at /ga and /ga-done. |
| +func copyPages() map[string]string { |
| + ga := `<script src="/i18n/ga.js"></script>` + copyTag |
| + return map[string]string{ |
| + "/": snippetDoc("en", snippetMarkup, copyTag), |
| + "/class": snippetDoc("en", classSnippet, copyTag), |
| + "/ga": snippetDoc("ga", tableSnippet(""), ga), |
| + "/ga-done": snippetDoc("ga", tableSnippet("Done"), ga), |
| + } |
| +} |
| + |
| +// copyRig serves copyPages with the tree's scripts, and i18n/<lang>.js |
| +// from the release build, which is the only thing that writes them. |
| +func copyRig(t *testing.T) *harness.Rig { |
| + t.Helper() |
| + dist := distScripts(t) |
| + scripts := func(name string) ([]byte, bool) { |
| + if strings.HasPrefix(name, "i18n/") { |
| + return dist(name) |
| + } |
| + return fiesta.Script(name) |
| + } |
| + pages := copyPages() |
| + return harness.New(t, func(string) http.Handler { return sizingMuxWith(t, pages, scripts) }) |
| +} |
| + |
| +// copyWords is one language's copy-done and copy-failed, from its table. |
| +func copyWords(t *testing.T, lang string) (done, failed string) { |
| + t.Helper() |
| + b, ok := fiesta.Strings(lang) |
| + if !ok { |
| + t.Fatalf("no table for %s", lang) |
| + } |
| + var table map[string]any |
| + if err := json.Unmarshal(b, &table); err != nil { |
| + t.Fatal(err) |
| + } |
| + done, _ = table["copy-done"].(string) |
| + failed, _ = table["copy-failed"].(string) |
| + if done == "" || failed == "" { |
| + t.Fatalf("i18n/%s.json has copy-done %q and copy-failed %q; want both", lang, done, failed) |
| + } |
| + return done, failed |
| +} |
| + |
| +// grantClipboard lets the origin read and write the clipboard without |
| +// asking, and gives the page focus: the clipboard API refuses a |
| +// document without focus, and a headless page has none unless told. |
| +func grantClipboard(t *testing.T, ctx context.Context, origin string) { |
| + t.Helper() |
| + for _, name := range []string{"clipboard-read", "clipboard-write"} { |
| + mustRun(t, ctx, browser.SetPermission(&browser.PermissionDescriptor{Name: name}, browser.PermissionSettingGranted).WithOrigin(origin)) |
| + } |
| + mustRun(t, ctx, emulation.SetFocusEmulationEnabled(true)) |
| +} |
| + |
| +// beforeLoad runs js in every document this tab loads from now on, |
| +// before any of the page's own scripts. |
| +func beforeLoad(t *testing.T, ctx context.Context, js string) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.ActionFunc(func(ctx context.Context) error { |
| + _, err := cdppage.AddScriptToEvaluateOnNewDocument(js).Do(ctx) |
| + return err |
| + })) |
| +} |
| + |
| +// The first snippet's parts, in either spelling, as copy.js finds them. |
| +const snippetParts = `const box = document.querySelector("[rst-snippet],.rst-snippet"), |
| + b = box.querySelector("[rst-snippet-copy],.rst-snippet__copy"), |
| + s = box.querySelector("[rst-snippet-status],.rst-snippet__status"), |
| + code = box.querySelector("code");` |
| + |
| +type snippetReading struct { |
| + Hidden, Shown bool |
| + Label, Status, Code, Selected string |
| +} |
| + |
| +func readSnippet(t *testing.T, ctx context.Context) snippetReading { |
| + t.Helper() |
| + var r snippetReading |
| + at(t, ctx, `(() => { `+snippetParts+` |
| + return JSON.stringify({Hidden: b.hidden, Shown: b.getClientRects().length > 0, Label: b.textContent.trim(), |
| + Status: s.textContent, Code: code.textContent, Selected: getSelection().toString()}); })()`, &r) |
| + return r |
| +} |
| + |
| +// openSnippet loads path and fails at once if copy.js did not show the |
| +// button: every later step clicks it, and a click on a hidden button |
| +// would wait out the deadline instead of saying why. |
| +func openSnippet(t *testing.T, ctx context.Context, origin, path string) snippetReading { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(origin+path)) |
| + r := readSnippet(t, ctx) |
| + if r.Hidden || !r.Shown { |
| + t.Fatalf("%s: the Copy button is hidden=%v shown=%v once the page has loaded; copy.js should show it where there is a clipboard", path, r.Hidden, r.Shown) |
| + } |
| + return r |
| +} |
| + |
| +// copyLog is what the recorder saw. Status and Label are the text each |
| +// write left, in order, Status opening with the text before the first |
| +// click; Clicks are when each click reached the button, and After is |
| +// the snippet straight after copy.js handled each click. Times are |
| +// performance.now(), in ms. |
| +type copyLog struct { |
| + Status, Label []struct { |
| + T float64 |
| + Text string |
| + } |
| + Clicks []float64 |
| + After []struct{ Status, Label string } |
| +} |
| + |
| +// recordCopies installs the recorder. A write is one childList record, |
| +// since copy.js sets textContent, and its text is the nodes it added: |
| +// so two writes in one task are two entries, not their last state. |
| +// The click listener on the document is added after copy.js's, so it |
| +// runs after it. |
| +func recordCopies(t *testing.T, ctx context.Context) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { `+snippetParts+` |
| + const now = () => performance.now(), added = (r) => [...r.addedNodes].map((n) => n.textContent).join(""); |
| + const log = window.copyLog = {Status: [{T: now(), Text: s.textContent}], Label: [], Clicks: [], After: []}; |
| + new MutationObserver((rs) => rs.forEach((r) => log.Status.push({T: now(), Text: added(r)}))).observe(s, {childList: true}); |
| + new MutationObserver((rs) => rs.forEach((r) => log.Label.push({T: now(), Text: added(r).trim()}))).observe(b, {childList: true}); |
| + b.addEventListener("click", () => log.Clicks.push(now()), true); |
| + document.addEventListener("click", (e) => { if (e.target === b) log.After.push({Status: s.textContent, Label: b.textContent.trim()}); }); |
| + getSelection().removeAllRanges(); |
| + return true; })()`, nil)) |
| +} |
| + |
| +func readLog(t *testing.T, ctx context.Context) copyLog { |
| + t.Helper() |
| + var l copyLog |
| + at(t, ctx, `JSON.stringify(window.copyLog)`, &l) |
| + return l |
| +} |
| + |
| +// statusTexts is the status's record as text alone. |
| +func (l copyLog) statusTexts() []string { |
| + var out []string |
| + for _, w := range l.Status { |
| + out = append(out, w.Text) |
| + } |
| + return out |
| +} |
| + |
| +// spoken is every non-empty status write. |
| +func (l copyLog) spoken() []string { |
| + var out []string |
| + for _, w := range l.Status { |
| + if w.Text != "" { |
| + out = append(out, w.Text) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// stubWrites replaces navigator.clipboard.writeText with one that |
| +// settles its calls as steps says, in order (each {ms, ok}), and |
| +// records in window.copyWrites when each settled. |
| +func stubWrites(t *testing.T, ctx context.Context, steps string) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { const steps = `+steps+`; window.copyWrites = []; |
| + navigator.clipboard.writeText = (text) => { |
| + const step = steps[copyWrites.length] || {ms: 0, ok: true}, rec = {Text: text, Settled: 0}; |
| + copyWrites.push(rec); |
| + return new Promise((ok, no) => setTimeout(() => { rec.Settled = performance.now(); step.ok ? ok() : no(new DOMException("refused", "NotAllowedError")); }, step.ms)); |
| + }; |
| + return true; })()`, nil)) |
| +} |
| + |
| +// clickTwice clicks the button in the page, then again gap ms later: |
| +// in the page, so the gap is the page's own clock and not a round trip |
| +// through CDP that a loaded machine stretches past copy.js's 100 ms. |
| +func clickTwice(t *testing.T, ctx context.Context, gap int) { |
| + t.Helper() |
| + mustRun(t, ctx, chromedp.Evaluate(`(() => { `+snippetParts+` b.click(); setTimeout(() => b.click(), `+strconv.Itoa(gap)+`); return true; })()`, nil)) |
| +} |
| + |
| +// soonTrue polls a page expression for up to five seconds and reports |
| +// whether it came true, for a drive that names what never happened |
| +// rather than the expression it waited on. |
| +func soonTrue(ctx context.Context, expr string) bool { |
| + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) { |
| + var yes bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(expr, &yes)); err == nil && yes { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// driveCopy is the journey a granted clipboard takes, on the page at |
| +// path, whose snippet is in English: the button is shown, a click puts |
| +// the code's text on the clipboard, says Copied on the label at once |
| +// and in the status no sooner than 100 ms after an emptied status, and |
| +// two seconds on the label is back. A second click straight after the |
| +// first's message does the same, and its label holds for its own two |
| +// seconds, not what was left of the first's. |
| +func driveCopy(t *testing.T, ctx context.Context, origin, path string) { |
| + t.Helper() |
| + done, _ := copyWords(t, "en") |
| + grantClipboard(t, ctx, origin) |
| + first := openSnippet(t, ctx, origin, path) |
| + if first.Label == "" || first.Label == done { |
| + t.Fatalf("%s: the button reads %q before any click; want its own label", path, first.Label) |
| + } |
| + recordCopies(t, ctx) |
| + button := "[rst-snippet-copy],.rst-snippet__copy" |
| + for i := 0; i < 2; i++ { |
| + mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.writeText("")`, nil, awaitPromise), |
| + chromedp.Click(button, chromedp.ByQuery)) |
| + if !soonTrue(ctx, `window.copyLog.Status.length > 1 && window.copyLog.Status[window.copyLog.Status.length - 1].Text !== ""`) { |
| + t.Fatalf("%s, click %d: the status never spoke; want %q within a second or so", path, i+1, done) |
| + } |
| + var held string |
| + mustRun(t, ctx, chromedp.Evaluate(`navigator.clipboard.readText()`, &held, awaitPromise)) |
| + r, l := readSnippet(t, ctx), readLog(t, ctx) |
| + if held != r.Code { |
| + t.Errorf("%s, click %d: the clipboard holds %q; want the code, %q", path, i+1, held, r.Code) |
| + } |
| + if r.Label != done || r.Status != done { |
| + t.Errorf("%s, click %d: the label reads %q and the status %q; want %q in both", path, i+1, r.Label, r.Status, done) |
| + } |
| + if len(l.Clicks) != i+1 || len(l.After) != i+1 { |
| + t.Fatalf("%s, click %d: the recorder saw %d clicks; the click did not land", path, i+1, len(l.Clicks)) |
| + } |
| + if a := l.After[i]; a.Status != "" || a.Label != first.Label { |
| + t.Errorf("%s, click %d: straight after the click the status says %q and the label %q; want it empty and %q", path, i+1, a.Status, a.Label, first.Label) |
| + } |
| + said := l.Status[len(l.Status)-1] |
| + if wait := said.T - l.Clicks[i]; wait < 100 { |
| + t.Errorf("%s, click %d: the status spoke %.0f ms after the click; want 100 ms or more, so an emptied region changes twice", path, i+1, wait) |
| + } |
| + } |
| + settleUntil(t, ctx, `(() => { `+snippetParts+` return b.textContent.trim() === `+jsStr(first.Label)+`; })()`) |
| + l := readLog(t, ctx) |
| + back := l.Label[len(l.Label)-1] |
| + if held := back.T - l.Clicks[1]; held < 2000 || held > 3000 { |
| + t.Errorf("%s: the label came back %.0f ms after the second click; want two seconds, the second click's own", path, held) |
| + } |
| +} |
| + |
| +// TestCopyCopiesAndSaysSo: the spec's snippet, a granted clipboard, |
| +// driveCopy's journey. |
| +func TestCopyCopiesAndSaysSo(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + driveCopy(t, ctx, rig.Origin, "/") |
| + rig.Screen("body", "the snippet after two copies") |
| +} |
| + |
| +// TestAClassSpelledSnippetCopies: the same journey on the class |
| +// spelling, whose button carries no strings, so Copied is copy.js's |
| +// own English and has to match the English table. |
| +func TestAClassSpelledSnippetCopies(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + driveCopy(t, ctx, rig.Origin, "/class") |
| +} |
| + |
| +// TestASuccessThenAFailureEndsOnTheFailure: a write that works, then |
| +// 300 ms later one that fails. The first says Copied; the second puts |
| +// the label back and keeps it, says it failed, and selects the code. |
| +// Without the click restoring the label, Copied would stay on a button |
| +// whose last copy failed. |
| +func TestASuccessThenAFailureEndsOnTheFailure(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + done, failed := copyWords(t, "en") |
| + label := openSnippet(t, ctx, rig.Origin, "/").Label |
| + stubWrites(t, ctx, `[{ms: 0, ok: true}, {ms: 0, ok: false}]`) |
| + recordCopies(t, ctx) |
| + clickTwice(t, ctx, 300) |
| + settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites[1].Settled > 0`) |
| + if r := readSnippet(t, ctx); r.Label != label { |
| + t.Errorf("straight after the failed copy settled, the label reads %q; want %q", r.Label, label) |
| + } |
| + mustRun(t, ctx, chromedp.Sleep(2500*time.Millisecond)) |
| + r, l := readSnippet(t, ctx), readLog(t, ctx) |
| + if r.Label != label { |
| + t.Errorf("2.5 s after the failed copy the label reads %q; want %q", r.Label, label) |
| + } |
| + if want := []string{"", done, "", failed}; !slices.Equal(l.statusTexts(), want) { |
| + t.Errorf("the status was written %q; want %q", l.statusTexts(), want) |
| + } |
| + if r.Selected != r.Code { |
| + t.Errorf("after the failed copy the selection is %q; want the code, %q", r.Selected, r.Code) |
| + } |
| +} |
| + |
| +// TestWritesThatSettleOutOfOrderEndOnTheLast: two clicks 50 ms apart |
| +// whose writes settle in the other order. The late first write is not |
| +// the latest request, so whatever it says is dropped: a failure that |
| +// settles late selects nothing and says nothing, and a success that |
| +// settles late does not overwrite the failure that came after it. |
| +func TestWritesThatSettleOutOfOrderEndOnTheLast(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + done, failed := copyWords(t, "en") |
| + for _, leg := range []struct { |
| + name, steps string |
| + status, said string |
| + label, selected bool |
| + }{ |
| + {"the first works late, the second fails at once", `[{ms: 400, ok: true}, {ms: 0, ok: false}]`, failed, failed, false, true}, |
| + {"the first fails late, the second works at once", `[{ms: 400, ok: false}, {ms: 0, ok: true}]`, done, done, true, false}, |
| + } { |
| + t.Run(leg.name, func(t *testing.T) { |
| + label := openSnippet(t, ctx, rig.Origin, "/").Label |
| + stubWrites(t, ctx, leg.steps) |
| + recordCopies(t, ctx) |
| + clickTwice(t, ctx, 50) |
| + settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites.every((w) => w.Settled > 0)`) |
| + mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond)) |
| + r, l := readSnippet(t, ctx), readLog(t, ctx) |
| + want := label |
| + if leg.label { |
| + want = done |
| + } |
| + if r.Status != leg.status || r.Label != want { |
| + t.Errorf("after both settled the status says %q and the label %q; want %q and %q", r.Status, r.Label, leg.status, want) |
| + } |
| + if got := l.spoken(); !slices.Equal(got, []string{leg.said}) { |
| + t.Errorf("the status spoke %q; want only the second request's %q", got, leg.said) |
| + } |
| + if leg.selected && r.Selected != r.Code { |
| + t.Errorf("the selection is %q; want the code, %q", r.Selected, r.Code) |
| + } |
| + if !leg.selected && r.Selected != "" { |
| + t.Errorf("the selection is %q; a failure that is not the latest request should select nothing", r.Selected) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestTheStatusIsALiveRegionBeforeItSpeaks: the status is in the |
| +// accessibility tree as a status before it has said anything, since a |
| +// region that appears with its first message may not be announced. A |
| +// region written the text it already holds has not changed, so every |
| +// message follows an emptied status; and a message is only ever the |
| +// latest request's, so clicks closer together than the 100 ms delay, |
| +// or a click while an earlier write is pending, speak once. |
| +func TestTheStatusIsALiveRegionBeforeItSpeaks(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + done, failed := copyWords(t, "en") |
| + grantClipboard(t, ctx, rig.Origin) |
| + |
| + t.Run("in the tree before any click", func(t *testing.T) { |
| + openSnippet(t, ctx, rig.Origin, "/") |
| + var nodes []*cdp.Node |
| + mustRun(t, ctx, chromedp.Nodes("[rst-snippet-status]", &nodes, chromedp.ByQuery)) |
| + // Read loosely: cdproto's Node refuses an ignored reason it has |
| + // no constant for (Chromium's "uninteresting"), and this needs |
| + // only the role, the ignored flag and the DOM node. |
| + var tree struct { |
| + Nodes []struct { |
| + Ignored bool `json:"ignored"` |
| + Role *struct { |
| + Value any `json:"value"` |
| + } `json:"role"` |
| + BackendDOMNodeID cdp.BackendNodeID `json:"backendDOMNodeId"` |
| + } `json:"nodes"` |
| + } |
| + mustRun(t, ctx, accessibility.Enable(), chromedp.ActionFunc(func(ctx context.Context) error { |
| + return cdp.Execute(ctx, accessibility.CommandGetFullAXTree, accessibility.GetFullAXTree(), &tree) |
| + })) |
| + found := false |
| + for _, n := range tree.Nodes { |
| + if n.BackendDOMNodeID == nodes[0].BackendNodeID { |
| + found = !n.Ignored && n.Role != nil && n.Role.Value == "status" |
| + } |
| + } |
| + if len(tree.Nodes) == 0 { |
| + t.Fatal("the accessibility tree came back empty; the read is broken, not the status") |
| + } |
| + if !found { |
| + t.Error("before any click the snippet's status is not a status node in the accessibility tree") |
| + } |
| + }) |
| + |
| + t.Run("two clicks a second apart", func(t *testing.T) { |
| + openSnippet(t, ctx, rig.Origin, "/") |
| + recordCopies(t, ctx) |
| + clickTwice(t, ctx, 1000) |
| + settleUntil(t, ctx, `window.copyLog.Status.length >= 4`) |
| + mustRun(t, ctx, chromedp.Sleep(300*time.Millisecond)) |
| + if got, want := readLog(t, ctx).statusTexts(), []string{"", done, "", done}; !slices.Equal(got, want) { |
| + t.Errorf("the status was written %q; want %q, two changes the screen reader can hear", got, want) |
| + } |
| + }) |
| + |
| + t.Run("two clicks 50 ms apart", func(t *testing.T) { |
| + openSnippet(t, ctx, rig.Origin, "/") |
| + recordCopies(t, ctx) |
| + clickTwice(t, ctx, 50) |
| + settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`) |
| + mustRun(t, ctx, chromedp.Sleep(400*time.Millisecond)) |
| + l := readLog(t, ctx) |
| + if got, want := l.statusTexts(), []string{"", done}; !slices.Equal(got, want) { |
| + t.Errorf("the status was written %q; want %q, one announcement for the latest click", got, want) |
| + } |
| + if len(l.After) != 2 || l.After[1].Status != "" { |
| + t.Errorf("straight after the second click the status read %+v; want it empty", l.After) |
| + } |
| + if said := l.Status[len(l.Status)-1]; len(l.Clicks) == 2 && said.T-l.Clicks[1] < 100 { |
| + t.Errorf("the status spoke %.0f ms after the second click; want 100 ms or more", said.T-l.Clicks[1]) |
| + } |
| + }) |
| + |
| + t.Run("a click while the first write is pending", func(t *testing.T) { |
| + label := openSnippet(t, ctx, rig.Origin, "/").Label |
| + stubWrites(t, ctx, `[{ms: 0, ok: true}, {ms: 500, ok: false}]`) |
| + recordCopies(t, ctx) |
| + clickTwice(t, ctx, 50) |
| + settleUntil(t, ctx, `window.copyWrites.length === 2 && window.copyWrites[1].Settled > 0 && window.copyLog.Status.some((w) => w.Text !== "")`) |
| + // Past the first click's two-second reset, so a reset that |
| + // escaped the request number would show. |
| + mustRun(t, ctx, chromedp.Sleep(2000*time.Millisecond)) |
| + l := readLog(t, ctx) |
| + var settled float64 |
| + at(t, ctx, `JSON.stringify(window.copyWrites[1].Settled)`, &settled) |
| + if got := l.spoken(); !slices.Equal(got, []string{failed}) { |
| + t.Errorf("the status spoke %q; want only %q, the pending request's", got, failed) |
| + } |
| + for _, w := range l.Status { |
| + if w.Text != "" && w.T < settled { |
| + t.Errorf("the status said %q at %.0f ms, before the pending write settled at %.0f ms", w.Text, w.T, settled) |
| + } |
| + } |
| + if len(l.Clicks) != 2 { |
| + t.Fatalf("the recorder saw %d clicks; want 2", len(l.Clicks)) |
| + } |
| + for _, w := range l.Label { |
| + if w.T >= l.Clicks[1] && w.Text != label { |
| + t.Errorf("after the second click the label was written %q at %.0f ms; want %q throughout", w.Text, w.T-l.Clicks[1], label) |
| + } |
| + } |
| + if r := readSnippet(t, ctx); r.Label != label { |
| + t.Errorf("the label reads %q; want %q", r.Label, label) |
| + } |
| + }) |
| +} |
| + |
| +// TestACopyThatFailsSelectsTheCode: a clipboard that refuses, by a |
| +// rejected promise and then by a throw. Either way the code is |
| +// selected, so it can be copied by hand, the status says so, and the |
| +// label never claims a copy. |
| +func TestACopyThatFailsSelectsTheCode(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + _, failed := copyWords(t, "en") |
| + for _, leg := range []struct{ name, init string }{ |
| + {"rejected", `navigator.clipboard.writeText = () => Promise.reject(new DOMException("Write permission denied.", "NotAllowedError"));`}, |
| + {"thrown", `navigator.clipboard.writeText = () => { throw new DOMException("Write permission denied.", "NotAllowedError"); };`}, |
| + } { |
| + t.Run(leg.name, func(t *testing.T) { |
| + beforeLoad(t, ctx, leg.init) |
| + label := openSnippet(t, ctx, rig.Origin, "/").Label |
| + recordCopies(t, ctx) |
| + mustRun(t, ctx, chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery)) |
| + settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`) |
| + r, l := readSnippet(t, ctx), readLog(t, ctx) |
| + if r.Selected != r.Code { |
| + t.Errorf("after a %s copy the selection is %q; want the code, %q", leg.name, r.Selected, r.Code) |
| + } |
| + if r.Status != failed { |
| + t.Errorf("after a %s copy the status says %q; want %q", leg.name, r.Status, failed) |
| + } |
| + for _, w := range l.Label { |
| + if w.Text != label { |
| + t.Errorf("after a %s copy the label was written %q; want %q throughout", leg.name, w.Text, label) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestCopyStaysHiddenWithoutAClipboard: with no clipboard API copy.js |
| +// runs (its guard is set) and leaves the button hidden; with scripts |
| +// off the markup's own hidden keeps it so. Either way no button that |
| +// does nothing. |
| +func TestCopyStaysHiddenWithoutAClipboard(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + hidden := func(t *testing.T, where string) { |
| + t.Helper() |
| + if r := readSnippet(t, ctx); !r.Hidden || r.Shown { |
| + t.Errorf("%s the Copy button is hidden=%v shown=%v; want it hidden", where, r.Hidden, r.Shown) |
| + } |
| + } |
| + |
| + t.Run("no clipboard API", func(t *testing.T) { |
| + beforeLoad(t, ctx, `Object.defineProperty(navigator, "clipboard", { value: undefined });`) |
| + mustRun(t, ctx, chromedp.EmulateViewport(1280, 900), chromedp.Navigate(rig.Origin+"/")) |
| + var ran bool |
| + mustRun(t, ctx, chromedp.Evaluate(`window.fiestaCopy === true`, &ran)) |
| + if !ran { |
| + t.Error("copy.js did not run: window.fiestaCopy is not set, so this leg proves nothing about it") |
| + } |
| + hidden(t, "with no clipboard API") |
| + }) |
| + |
| + t.Run("scripts off", func(t *testing.T) { |
| + mustRun(t, ctx, emulation.SetScriptExecutionDisabled(true), chromedp.Navigate(rig.Origin+"/")) |
| + hidden(t, "with scripts off") |
| + mustRun(t, ctx, emulation.SetScriptExecutionDisabled(false)) |
| + }) |
| +} |
| + |
| +// TestCopyReadsTheTable: an Irish page with Irish strings loaded and |
| +// none on the button says the table's copy-done; the button's own |
| +// data-rst-copy-done wins over the table. |
| +func TestCopyReadsTheTable(t *testing.T) { |
| + rig := copyRig(t) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + done, _ := copyWords(t, "ga") |
| + grantClipboard(t, ctx, rig.Origin) |
| + for _, leg := range []struct{ path, want string }{{"/ga", done}, {"/ga-done", "Done"}} { |
| + openSnippet(t, ctx, rig.Origin, leg.path) |
| + recordCopies(t, ctx) |
| + mustRun(t, ctx, chromedp.Click("[rst-snippet-copy]", chromedp.ByQuery)) |
| + settleUntil(t, ctx, `window.copyLog.Status.some((w) => w.Text !== "")`) |
| + if r := readSnippet(t, ctx); r.Status != leg.want || r.Label != leg.want { |
| + t.Errorf("%s: the status says %q and the label %q; want %q in both", leg.path, r.Status, r.Label, leg.want) |
| + } |
| + } |
| +} |
diff --git a/check/release_browser_test.go b/check/release_browser_test.go| index 0e3a798..f923c6c 100644 |
| --- a/check/release_browser_test.go |
| +++ b/check/release_browser_test.go |
| @@ -108,10 +108,11 @@ func TestTheReleaseRendersAsTheSource(t *testing.T) { |
| } |
| } |
| -// TestTheReleaseScriptsDriveTheJourneys runs four drives, each on a |
| +// TestTheReleaseScriptsDriveTheJourneys runs five drives, each on a |
| // rig whose scripts are the release's: the switch drag, the |
| -// remembered scheme, the select filter and the calendar. Each drive's |
| -// pages put their own document at /, so each has a rig of its own. |
| +// remembered scheme, the select filter, the calendar and the |
| +// snippet's copy. Each drive's pages put their own document at /, so |
| +// each has a rig of its own. |
| func TestTheReleaseScriptsDriveTheJourneys(t *testing.T) { |
| scripts := distScripts(t) |
| if b, ok := scripts("fiesta.js"); !ok || !bytes.HasPrefix(b, []byte(build.Banner(fiesta.Version))) { |
| @@ -126,6 +127,11 @@ func TestTheReleaseScriptsDriveTheJourneys(t *testing.T) { |
| {"scheme remember", schemePages(), driveSchemeRemember}, |
| {"select filter", selectFilterPages(t), driveSelectFilter}, |
| {"calendar open", calendarPages(t), driveCalendarOpen}, |
| + {"snippet copy", copyPages(), func(t *testing.T, rig *harness.Rig) { |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + driveCopy(t, ctx, rig.Origin, "/") |
| + }}, |
| } { |
| t.Run(drive.name, func(t *testing.T) { |
| pages := drive.pages |
diff --git a/check/scripts_test.go b/check/scripts_test.go| index 354085c..67be589 100644 |
| --- a/check/scripts_test.go |
| +++ b/check/scripts_test.go |
| @@ -576,8 +576,8 @@ func TestSelectContract(t *testing.T) { |
| // pinned so a list that lost its names could not pass by naming none. |
| func TestScriptsAreSelfContained(t *testing.T) { |
| names := fiesta.ScriptNames() |
| - if len(names) != 8 { |
| - t.Fatalf("fiesta.ScriptNames() lists %d scripts, want 8: %v", len(names), names) |
| + if len(names) != 9 { |
| + t.Fatalf("fiesta.ScriptNames() lists %d scripts, want 9: %v", len(names), names) |
| } |
| for _, name := range names { |
| js := string(shippedScript(t, name)) |
| @@ -676,3 +676,45 @@ func TestShellContract(t *testing.T) { |
| t.Fatalf("shell.js is %d bytes; keep it readable in one sitting (8 KiB)", n) |
| } |
| } |
| + |
| +// copy.js holds to the contract every scaffolded script does, with the |
| +// promise its header makes: where there is no clipboard API it returns |
| +// before it listens for anything, so the buttons stay hidden rather |
| +// than showing a button that does nothing. It reads the button by its |
| +// attribute and owns one guard on the window, and it carries the string |
| +// lookup select.js carries, byte for byte, so an attribute, a page's |
| +// table and the English fall back in the same order in every script. |
| +func TestCopyContract(t *testing.T) { |
| + js := string(shippedScript(t, "copy.js")) |
| + listen := strings.Index(js, "addEventListener") |
| + test := strings.Index(js, "typeof navigator.clipboard?.writeText") |
| + if test < 0 { |
| + test = strings.Index(js, "navigator.clipboard && navigator.clipboard.writeText") |
| + } |
| + if test < 0 || listen < 0 || test > listen { |
| + t.Error("copy.js must test for navigator.clipboard.writeText, and return, before it listens for anything") |
| + } |
| + for _, want := range []string{"rst-snippet-copy", "fiestaCopy"} { |
| + if !strings.Contains(js, want) { |
| + t.Errorf("copy.js does not mention %q", want) |
| + } |
| + } |
| + sel := string(shippedScript(t, "select.js")) |
| + for _, name := range []string{"fiestaPickLang", "fiestaWord"} { |
| + mine, theirs := functionBody(js, name), functionBody(sel, name) |
| + if mine == "" { |
| + t.Errorf("copy.js does not carry %s", name) |
| + } else if mine != theirs { |
| + t.Errorf("%s differs between copy.js and select.js:\n--- copy.js\n%s\n--- select.js\n%s", name, mine, theirs) |
| + } |
| + } |
| + if !strings.HasPrefix(strings.TrimSpace(js), "/*") || !strings.Contains(js, "(function () {") || !strings.HasSuffix(strings.TrimSpace(js), "})();") { |
| + t.Error("copy.js should be its contract comment and a single IIFE") |
| + } |
| + if strings.Contains(js, "eval(") || strings.Contains(js, "new Function") || strings.Contains(js, "\t") { |
| + t.Error("copy.js must stay CSP-clean and use two-space indentation") |
| + } |
| + if n := len(js); n > 8*1024 { |
| + t.Fatalf("copy.js is %d bytes; it does one thing, keep it readable in one sitting (8 KiB)", n) |
| + } |
| +} |
diff --git a/check/strings_test.go b/check/strings_test.go| index 1ec0995..f7f8559 100644 |
| --- a/check/strings_test.go |
| +++ b/check/strings_test.go |
| @@ -13,8 +13,8 @@ import ( |
| "amadan.net/rastrillo/fiesta/internal/nodetest" |
| ) |
| -// The interface strings datetime.js and select.js put on screen, one |
| -// flat JSON object per shipped language under i18n/, seeded from |
| +// The interface strings datetime.js, select.js and copy.js put on |
| +// screen, one flat JSON object per shipped language under i18n/, seeded from |
| // rastrillo's catalogs at the seed commit and read through fiesta.Strings. The |
| // scripts look a string up in three places, in this order: the |
| // element's own data-rst- attribute, then the page's table |
| @@ -23,14 +23,15 @@ import ( |
| // These tests hold the tables' shape and the lookup, in Node |
| // (node/strings_node.mjs) and in a browser (strings_browser_test.go). |
| -// languageKeys are the twenty string keys, each an attribute's name |
| -// with data-rst- removed. |
| +// languageKeys are the twenty-two string keys, each an attribute's |
| +// name with data-rst- removed. |
| var languageKeys = []string{ |
| "date-set", "date-hint", "date-pick", "date-pick-time", "date-calendar", |
| "date-prev-month", "date-next-month", "date-results", "date-result-one", |
| "date-quick-today", "date-quick-tomorrow", "date-quick-next-week", |
| "date-quick-plus-1h", "date-quick-plus-2h", "date-quick-end-of-day", "date-quick-next-day", |
| "select-filter", "select-results", "select-result-one", "select-no-matches", |
| + "copy-done", "copy-failed", |
| } |
| // dateWords are the seventeen names under date-words. |
| @@ -106,7 +107,7 @@ func languageFileProblems(data []byte) []string { |
| } |
| // TestEveryLanguageHasEveryKey: twelve languages, each read through |
| -// fiesta.Strings, with exactly twenty-one keys, exactly seventeen |
| +// fiesta.Strings, with exactly twenty-three keys, exactly seventeen |
| // date-words names, no empty value, and {example} in date-hint. The |
| // precondition is not empty: fiesta.Languages() is the twelve tags |
| // with en first, i18n/ on disk holds exactly those files, and the |
diff --git a/fiesta.go b/fiesta.go| index e1d4bb3..e16946a 100644 |
| --- a/fiesta.go |
| +++ b/fiesta.go |
| @@ -76,10 +76,10 @@ func ThemeCSS(name string) ([]byte, bool) { |
| // wants them all would load them: fiesta.js first, since it restores a |
| // remembered colour scheme and owns the menus every page has, busy and |
| // light next, since they touch every button, the shells' navigation |
| -// after that, then the controls, and calendar.js last because it is |
| -// the grid datetime.js asks it for. The slice matches the files in js/ |
| -// exactly. |
| -var scriptNames = []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"} |
| +// after that, then the snippet's Copy button and the controls, and |
| +// calendar.js last because it is the grid datetime.js asks it for. The |
| +// slice matches the files in js/ exactly. |
| +var scriptNames = []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "copy.js", "select.js", "datetime.js", "calendar.js"} |
| // ScriptNames returns the optional scripts' file names, in load order. |
| // The returned slice is a copy. |
| @@ -93,11 +93,12 @@ func Script(name string) ([]byte, bool) { |
| } |
| // languageNames lists the shipped languages, en first: one |
| -// i18n/<lang>.json each, holding the strings datetime.js and select.js |
| -// put on screen. The slice matches the files in i18n/ exactly; adding |
| -// a language means adding both. Plain pt is not a language here: it |
| -// is a test alias for pt-PT (check/testdata/datetime/retagged.json), |
| -// kept for a page whose lang still says pt. |
| +// i18n/<lang>.json each, holding the strings datetime.js, select.js |
| +// and copy.js put on screen. The slice matches the files in i18n/ |
| +// exactly; adding a language means adding both. Plain pt is not a |
| +// language here: it is a test alias for pt-PT |
| +// (check/testdata/datetime/retagged.json), kept for a page whose lang |
| +// still says pt. |
| var languageNames = []string{"en", "ar", "bn", "es", "ga", "hi", "ja", "pt-PT", "ru", "vi", "yue", "zh-Hans"} |
| // Languages returns the shipped languages' tags, en first. The |
diff --git a/fiesta_test.go b/fiesta_test.go| index 0ff91e8..3f29306 100644 |
| --- a/fiesta_test.go |
| +++ b/fiesta_test.go |
| @@ -103,7 +103,7 @@ func TestEveryThemeIsEmbedded(t *testing.T) { |
| } |
| func TestEveryScriptIsEmbedded(t *testing.T) { |
| - want := []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"} |
| + want := []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "copy.js", "select.js", "datetime.js", "calendar.js"} |
| if got := ScriptNames(); !slices.Equal(got, want) { |
| t.Fatalf("ScriptNames() = %q, want %q", got, want) |
| } |
diff --git a/i18n/ar.json b/i18n/ar.json| index f40f9dd..707eab4 100644 |
| --- a/i18n/ar.json |
| +++ b/i18n/ar.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "تم النسخ", |
| + "copy-failed": "تعذّر النسخ. حدّد الشيفرة وانسخها بنفسك.", |
| "date-calendar": "التقويم", |
| "date-hint": "جرّب: {example}", |
| "date-next-month": "الشهر التالي", |
diff --git a/i18n/bn.json b/i18n/bn.json| index 0c1a972..ae57c97 100644 |
| --- a/i18n/bn.json |
| +++ b/i18n/bn.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "কপি হয়েছে", |
| + "copy-failed": "কপি করা যায়নি। কোডটি বেছে নিয়ে নিজে কপি করুন।", |
| "date-calendar": "ক্যালেন্ডার", |
| "date-hint": "চেষ্টা করুন: {example}", |
| "date-next-month": "পরের মাস", |
diff --git a/i18n/en.json b/i18n/en.json| index 77bb1bb..8c85a8a 100644 |
| --- a/i18n/en.json |
| +++ b/i18n/en.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "Copied", |
| + "copy-failed": "Copy failed. Select the code and copy it yourself.", |
| "date-calendar": "Calendar", |
| "date-hint": "Try: {example}", |
| "date-next-month": "Next month", |
diff --git a/i18n/es.json b/i18n/es.json| index 46061ed..12bc3a8 100644 |
| --- a/i18n/es.json |
| +++ b/i18n/es.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "Copiado", |
| + "copy-failed": "No se pudo copiar. Selecciona el código y cópialo tú.", |
| "date-calendar": "Calendario", |
| "date-hint": "Prueba: {example}", |
| "date-next-month": "Mes siguiente", |
diff --git a/i18n/ga.json b/i18n/ga.json| index 7b18c32..223ce6f 100644 |
| --- a/i18n/ga.json |
| +++ b/i18n/ga.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "Cóipeáilte", |
| + "copy-failed": "Theip ar an gcóipeáil. Roghnaigh an cód agus cóipeáil é tú féin.", |
| "date-calendar": "Féilire", |
| "date-hint": "Bain triail as: {example}", |
| "date-next-month": "An chéad mhí eile", |
diff --git a/i18n/hi.json b/i18n/hi.json| index 914b789..9d14fa9 100644 |
| --- a/i18n/hi.json |
| +++ b/i18n/hi.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "कॉपी हो गया", |
| + "copy-failed": "कॉपी नहीं हो सका। कोड चुनें और खुद कॉपी करें।", |
| "date-calendar": "कैलेंडर", |
| "date-hint": "आज़माएँ: {example}", |
| "date-next-month": "अगला महीना", |
diff --git a/i18n/ja.json b/i18n/ja.json| index 810c6b1..51a4485 100644 |
| --- a/i18n/ja.json |
| +++ b/i18n/ja.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "コピーしました", |
| + "copy-failed": "コピーできませんでした。コードを選択して、ご自身でコピーしてください。", |
| "date-calendar": "カレンダー", |
| "date-hint": "入力例: {example}", |
| "date-next-month": "次の月", |
diff --git a/i18n/pt-PT.json b/i18n/pt-PT.json| index 56f1f39..dba59a7 100644 |
| --- a/i18n/pt-PT.json |
| +++ b/i18n/pt-PT.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "Copiado", |
| + "copy-failed": "Não foi possível copiar. Selecione o código e copie-o você.", |
| "date-calendar": "Calendário", |
| "date-hint": "Experimente: {example}", |
| "date-next-month": "Mês seguinte", |
diff --git a/i18n/ru.json b/i18n/ru.json| index 9d998f9..132940d 100644 |
| --- a/i18n/ru.json |
| +++ b/i18n/ru.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "Скопировано", |
| + "copy-failed": "Не удалось скопировать. Выделите код и скопируйте его сами.", |
| "date-calendar": "Календарь", |
| "date-hint": "Попробуйте: {example}", |
| "date-next-month": "Следующий месяц", |
diff --git a/i18n/vi.json b/i18n/vi.json| index 6e2750d..91210c5 100644 |
| --- a/i18n/vi.json |
| +++ b/i18n/vi.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "Đã sao chép", |
| + "copy-failed": "Không sao chép được. Hãy chọn đoạn mã và tự sao chép.", |
| "date-calendar": "Lịch", |
| "date-hint": "Thử: {example}", |
| "date-next-month": "Tháng sau", |
diff --git a/i18n/yue.json b/i18n/yue.json| index 10c5c0a..32e4b25 100644 |
| --- a/i18n/yue.json |
| +++ b/i18n/yue.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "複製咗", |
| + "copy-failed": "複製唔到。揀咗段代碼,自己複製啦。", |
| "date-calendar": "日曆", |
| "date-hint": "試下:{example}", |
| "date-next-month": "下個月", |
diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json| index 5045354..e20fc03 100644 |
| --- a/i18n/zh-Hans.json |
| +++ b/i18n/zh-Hans.json |
| @@ -1,4 +1,6 @@ |
| { |
| + "copy-done": "已复制", |
| + "copy-failed": "复制失败。请选中代码,自己复制。", |
| "date-calendar": "日历", |
| "date-hint": "试试:{example}", |
| "date-next-month": "下一个月", |
diff --git a/internal/gallery/copy.go b/internal/gallery/copy.go| index 193bb0b..b60cbb1 100644 |
| --- a/internal/gallery/copy.go |
| +++ b/internal/gallery/copy.go |
| @@ -133,6 +133,7 @@ var text = map[string]string{ |
| "gallery.started.file.light-js": "The light the depth styles read: shadows lit by the hour, and a sheen on the button under the pointer. Delete it for the still design.", |
| "gallery.started.file.anchor-js": "Keeps a dropdown's menu on the screen in browsers without CSS anchor positioning. It does nothing where they have it: delete it once every browser you support does.", |
| "gallery.started.file.shell-js": "Phone navigation for the sidebar and console shells: the slide between pages, Back that reuses history, and focus returned to the section you left. Deletable on its own.", |
| + "gallery.started.file.copy-js": "The snippet's Copy button: it copies the code, says Copied, and stays hidden where the browser has no clipboard. Deletable on its own.", |
| "gallery.started.file.select-js": "field-select's searchable combobox. Inert until a select opts in with data-rst-select, and deletable on its own.", |
| "gallery.started.file.datetime-js": "The date fields' natural-language input. Inert until a field opts in with data-rst-date or data-rst-time, and deletable on its own.", |
| "gallery.started.file.calendar-js": "The month grid that field's calendar button opens. Inert until datetime.js asks it for a panel, and deletable on its own: without it the button falls back to the browser's own picker.", |
| @@ -143,7 +144,7 @@ var text = map[string]string{ |
| "gallery.started.file.bundle": "Tokens and the {theme} theme in one file: every component class, every scale, and the colours, each declared once as a light-dark() pair.", |
| "gallery.started.file.fiesta-js": "The enhancements most pages want: menus that close on an outside click and on Escape, the switch's drag, iOS's pressed state, and the colour-scheme menu. Load it first.", |
| "gallery.started.file.busy-js": "The busy rule: while a form sends, its button shows a spinner for at least 650ms and refuses a second submit, and Back hands it back.", |
| - "gallery.started.file.strings": "The words select.js and datetime.js put on screen, in {language}. Load the one that matches your page's lang.", |
| + "gallery.started.file.strings": "The words select.js, datetime.js and copy.js put on screen, in {language}. Load the one that matches your page's lang.", |
| "gallery.started.file.sprite": "A sprite with one symbol per icon, for a site that serves its own copy of Fiesta. From the CDN, inline the SVG from icons/<slug>.svg instead.", |
| "gallery.started.total": "A page that links fiesta.css and every script gets {raw} of CSS and JavaScript as written, {gzip} gzipped.", |
| "gallery.started.link.heading": "The one link", |
diff --git a/internal/gallery/i18n/ar.json b/internal/gallery/i18n/ar.json| index 5e79bd2..a7c6156 100644 |
| --- a/internal/gallery/i18n/ar.json |
| +++ b/internal/gallery/i18n/ar.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "الرموز وسمة {theme} في ملف واحد: كل صنف مكوّن، وكل مقياس، والألوان، كل لون معلَن مرة واحدة كزوج light-dark().", |
| "gallery.started.file.busy-js": "قاعدة الانشغال: أثناء إرسال النموذج يعرض زرّه مؤشرًا دوّارًا لمدة 650ms على الأقل ويرفض إرسالًا ثانيًا، والرجوع يعيده كما كان.", |
| "gallery.started.file.calendar-js": "شبكة الشهر التي يفتحها زر التقويم في ذلك الحقل. خاملة حتى يطلب منها datetime.js لوحةً، وتُحذف وحدها: بدونها يعود الزر إلى منتقي المتصفّح نفسه.", |
| + "gallery.started.file.copy-js": "زر النسخ في المقتطف: ينسخ الشيفرة، ويعرض «تم النسخ»، ويبقى مخفيًا حيث لا توجد في المتصفح حافظة. يُحذف وحده.", |
| "gallery.started.file.datetime-js": "إدخال التواريخ باللغة الطبيعية. خامل حتى ينضمّ إليه حقل عبر data-rst-date أو data-rst-time، ويُحذف وحده.", |
| "gallery.started.file.fiesta-js": "التحسينات التي تريدها معظم الصفحات: قوائم تُغلق بنقرة خارجها وبمفتاح Escape، وسحب المفتاح، وحالة الضغط في iOS، وقائمة نظام الألوان. حمّله أولًا.", |
| "gallery.started.file.light-js": "الضوء الذي تقرأه أنماط العمق: ظلال مضاءة بحسب الساعة، ولمعة على الزر تحت المؤشر. احذفه لتحصل على التصميم الساكن.", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "الانزلاق بين الصفحات على الهاتف، للهيكلين sidebar وconsole. يُحذف مع shell.js.", |
| "gallery.started.file.shell-js": "التنقّل على الهاتف للهيكلين sidebar وconsole: الانزلاق بين الصفحات، وزر الرجوع الذي يعيد استخدام السجلّ، وإعادة التركيز إلى القسم الذي غادرته. يُحذف وحده.", |
| "gallery.started.file.sprite": "ملف sprite فيه عنصر symbol واحد لكل أيقونة، لموقع يقدّم نسخته الخاصة من Fiesta. ومن شبكة CDN، ضمّن ملف SVG من icons/<slug>.svg بدلًا منه.", |
| - "gallery.started.file.strings": "الكلمات التي يعرضها select.js وdatetime.js على الشاشة، بـ{language}. حمّل الملف الذي يطابق lang صفحتك.", |
| + "gallery.started.file.strings": "الكلمات التي يعرضها select.js وdatetime.js وcopy.js على الشاشة، بـ{language}. حمّل الملف الذي يطابق lang صفحتك.", |
| "gallery.started.file.theme": "اللون وعائلة الخط والشكل لسمة {theme}: كتلة :root واحدة يُعلَن فيها كل لون مرة واحدة كزوج light-dark().", |
| "gallery.started.file.tokens": "البنية: كل أصناف المكوّنات وكل السلالم، ولا قيمة لون صريحة في أي موضع. الملف نفسه تحت كل سمة.", |
| "gallery.started.lead": "Fiesta ورقة أنماط واحدة وحفنة من السكربتات الاختيارية. كل شيء يعمل مع إيقاف السكربتات؛ فالسكربتات تحسينات، كل واحد في ملفه، فتحمّل ما تستخدمه صفحتك ولا شيء غيره.", |
diff --git a/internal/gallery/i18n/bn.json b/internal/gallery/i18n/bn.json| index bb6493e..2c49689 100644 |
| --- a/internal/gallery/i18n/bn.json |
| +++ b/internal/gallery/i18n/bn.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "টোকেন আর {theme} থিম এক ফাইলে: প্রতিটি উপাদানের ক্লাস, প্রতিটি স্কেল, আর রংগুলো, প্রতিটি একবার light-dark() জোড়া হিসেবে ঘোষিত।", |
| "gallery.started.file.busy-js": "ব্যস্ততার নিয়ম: ফর্ম পাঠানোর সময় তার বোতাম অন্তত 650ms একটি স্পিনার দেখায় আর দ্বিতীয়বার পাঠানো ফিরিয়ে দেয়, আর ফিরে গেলে বোতামটি আগের মতো হয়।", |
| "gallery.started.file.calendar-js": "ওই ফিল্ডের ক্যালেন্ডার বোতাম যে মাসের গ্রিড খোলে। datetime.js প্যানেল না চাওয়া পর্যন্ত নিষ্ক্রিয়, আর আলাদা করে মুছে ফেলা যায়: এটি ছাড়া বোতামটি ব্রাউজারের নিজস্ব পিকারে ফিরে যায়।", |
| + "gallery.started.file.copy-js": "স্নিপেটের কপি বোতাম: কোডটি কপি করে, “কপি হয়েছে” দেখায়, আর যে ব্রাউজারে ক্লিপবোর্ড নেই সেখানে লুকানো থাকে। আলাদা করে মুছে ফেলা যায়।", |
| "gallery.started.file.datetime-js": "তারিখ ফিল্ডের স্বাভাবিক-ভাষার ইনপুট। কোনও ফিল্ড data-rst-date বা data-rst-time দিয়ে না চাইলে নিষ্ক্রিয়, আর আলাদা করে মুছে ফেলা যায়।", |
| "gallery.started.file.fiesta-js": "বেশির ভাগ পাতার চাওয়া উন্নতিগুলো: বাইরে ক্লিক করলে আর Escape চাপলে বন্ধ হওয়া মেনু, সুইচের টেনে সরানো, iOS-এর চাপা অবস্থা, আর রঙের ধরনের মেনু। এটি আগে লোড করুন।", |
| "gallery.started.file.light-js": "যে আলো গভীরতার স্টাইলগুলো পড়ে: সময় অনুযায়ী আলোকিত ছায়া, আর পয়েন্টারের নিচের বোতামে একটা দ্যুতি। স্থির ডিজ়াইনের জন্য এটি মুছে ফেলুন।", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "ফোনে পাতার মধ্যে স্লাইড, sidebar আর console শেলের জন্য। shell.js-এর সঙ্গে মুছে ফেলা যায়।", |
| "gallery.started.file.shell-js": "sidebar আর console শেলের ফোন নেভিগেশন: পাতার মধ্যে স্লাইড, ইতিহাস আবার কাজে লাগানো পেছনে, আর আপনি যে অংশ ছেড়ে গিয়েছিলেন সেখানে ফোকাস ফেরানো। আলাদা করে মুছে ফেলা যায়।", |
| "gallery.started.file.sprite": "প্রতিটি আইকনের জন্য একটি symbol সহ একটি স্প্রাইট, এমন সাইটের জন্য যা Fiesta-র নিজের কপি পরিবেশন করে। CDN থেকে নিলে বরং icons/<slug>.svg থেকে SVG ইনলাইন করুন।", |
| - "gallery.started.file.strings": "select.js আর datetime.js স্ক্রিনে যে শব্দগুলো দেখায়, {language}-এ। আপনার পাতার lang-এর সঙ্গে যেটি মেলে, সেটি লোড করুন।", |
| + "gallery.started.file.strings": "select.js, datetime.js আর copy.js স্ক্রিনে যে শব্দগুলো দেখায়, {language}-এ। আপনার পাতার lang-এর সঙ্গে যেটি মেলে, সেটি লোড করুন।", |
| "gallery.started.file.theme": "{theme} থিমের রঙ, টাইপ ফ্যামিলি আর আকার: একটিমাত্র :root ব্লক, যেখানে প্রতিটি রঙ একবার light-dark() জোড়া হিসেবে ঘোষিত।", |
| "gallery.started.file.tokens": "কাঠামো: প্রতিটি কম্পোনেন্ট ক্লাস, প্রতিটি স্কেল, আর কোথাও কোনও রঙের লিটারাল নেই। প্রতিটি থিমে একই ফাইল।", |
| "gallery.started.lead": "Fiesta হলো একটি স্টাইলশিট আর হাতেগোনা কয়েকটি ঐচ্ছিক স্ক্রিপ্ট। স্ক্রিপ্ট বন্ধ থাকলেও সব কাজ করে; স্ক্রিপ্টগুলো উন্নতি, প্রতিটি আলাদা ফাইলে, তাই আপনার পাতা যেগুলো ব্যবহার করে কেবল সেগুলোই লোড করুন, আর কিছু নয়।", |
diff --git a/internal/gallery/i18n/drafts.json b/internal/gallery/i18n/drafts.json| index d10136e..b55b90a 100644 |
| --- a/internal/gallery/i18n/drafts.json |
| +++ b/internal/gallery/i18n/drafts.json |
| @@ -1103,6 +1103,22 @@ |
| "zh-Hans" |
| ] |
| }, |
| + "gallery.started.file.copy-js": { |
| + "english": "The snippet's Copy button: it copies the code, says Copied, and stays hidden where the browser has no clipboard. Deletable on its own.", |
| + "locales": [ |
| + "ar", |
| + "bn", |
| + "es", |
| + "ga", |
| + "hi", |
| + "ja", |
| + "pt-PT", |
| + "ru", |
| + "vi", |
| + "yue", |
| + "zh-Hans" |
| + ] |
| + }, |
| "gallery.started.file.fiesta-js": { |
| "english": "The enhancements most pages want: menus that close on an outside click and on Escape, the switch's drag, iOS's pressed state, and the colour-scheme menu. Load it first.", |
| "locales": [ |
| @@ -1136,7 +1152,7 @@ |
| ] |
| }, |
| "gallery.started.file.strings": { |
| - "english": "The words select.js and datetime.js put on screen, in {language}. Load the one that matches your page's lang.", |
| + "english": "The words select.js, datetime.js and copy.js put on screen, in {language}. Load the one that matches your page's lang.", |
| "locales": [ |
| "ar", |
| "bn", |
diff --git a/internal/gallery/i18n/es.json b/internal/gallery/i18n/es.json| index 0de8ac9..344783e 100644 |
| --- a/internal/gallery/i18n/es.json |
| +++ b/internal/gallery/i18n/es.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "Los tokens y el tema {theme} en un solo archivo: cada clase de componente, cada escala y los colores, cada uno declarado una vez como par light-dark().", |
| "gallery.started.file.busy-js": "La regla de ocupado: mientras un formulario se envía, su botón muestra un indicador de carga durante al menos 650ms y rechaza un segundo envío; al volver atrás se restablece.", |
| "gallery.started.file.calendar-js": "La cuadrícula del mes que abre el botón de calendario de ese campo. Inerte hasta que datetime.js le pide un panel, y borrable por separado: sin ella, el botón recurre al selector propio del navegador.", |
| + "gallery.started.file.copy-js": "El botón Copiar del snippet: copia el código, dice Copiado y se queda oculto donde el navegador no tiene portapapeles. Borrable por separado.", |
| "gallery.started.file.datetime-js": "La entrada en lenguaje natural de los campos de fecha. Inerte hasta que un campo se apunta con data-rst-date o data-rst-time, y borrable por separado.", |
| "gallery.started.file.fiesta-js": "Las mejoras que quieren casi todas las páginas: menús que se cierran con un clic fuera y con Escape, el arrastre del interruptor, el estado pulsado de iOS y el menú de esquema de color. Cárgalo primero.", |
| "gallery.started.file.light-js": "La luz que leen los estilos de profundidad: sombras iluminadas según la hora, y un brillo en el botón bajo el puntero. Bórralo para el diseño estático.", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "El deslizamiento entre páginas en el móvil, para los shells sidebar y console. Borrable junto con shell.js.", |
| "gallery.started.file.shell-js": "La navegación en el móvil de los shells sidebar y console: el deslizamiento entre páginas, un Atrás que reutiliza el historial y el foco devuelto a la sección que dejaste. Borrable por separado.", |
| "gallery.started.file.sprite": "Un sprite con un symbol por icono, para un sitio que sirve su propia copia de Fiesta. Desde la CDN, inserta en su lugar el SVG de icons/<slug>.svg.", |
| - "gallery.started.file.strings": "Las palabras que select.js y datetime.js ponen en pantalla, en {language}. Carga el que coincida con el lang de tu página.", |
| + "gallery.started.file.strings": "Las palabras que select.js, datetime.js y copy.js ponen en pantalla, en {language}. Carga el que coincida con el lang de tu página.", |
| "gallery.started.file.theme": "Color, familia tipográfica y forma del tema {theme}: un único bloque :root donde cada color se declara una vez como par light-dark().", |
| "gallery.started.file.tokens": "Estructura: todas las clases de componentes, todas las escalas y ningún literal de color. El mismo archivo bajo cualquier tema.", |
| "gallery.started.lead": "Fiesta es una hoja de estilos y un puñado de scripts opcionales. Todo funciona sin scripts; los scripts son mejoras, cada uno en su propio archivo, así que cargas los que usa tu página y nada más.", |
diff --git a/internal/gallery/i18n/ga.json b/internal/gallery/i18n/ga.json| index 4e97d47..cab2834 100644 |
| --- a/internal/gallery/i18n/ga.json |
| +++ b/internal/gallery/i18n/ga.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "Na comharthaí agus an téama {theme} i gcomhad amháin: gach aicme comhpháirte, gach scála, agus na dathanna, gach ceann fógartha uair amháin mar phéire light-dark().", |
| "gallery.started.file.busy-js": "An riail ghnóthach: fad a sheoltar foirm, taispeánann a cnaipe sníomhaire ar feadh 650ms ar a laghad agus diúltaíonn sé don dara seoladh, agus tugann Siar ar ais é.", |
| "gallery.started.file.calendar-js": "An eangach míosa a osclaíonn cnaipe féilire an réimse sin. Marbh go dtí go n-iarrann datetime.js painéal air, agus inscriosta leis féin: gan é, filleann an cnaipe ar roghnóir an bhrabhsálaí féin.", |
| + "gallery.started.file.copy-js": "Cnaipe Cóipeáil na gearrthóige: cóipeálann sé an cód, deir sé Cóipeáilte, agus fanann sé i bhfolach áit nach bhfuil gearrthaisce ag an mbrabhsálaí. Inscriosta leis féin.", |
| "gallery.started.file.datetime-js": "Ionchur gnáththeanga na réimsí dáta. Marbh go dtí go roghnaíonn réimse isteach le data-rst-date nó data-rst-time, agus inscriosta leis féin.", |
| "gallery.started.file.fiesta-js": "Na feabhsuithe a theastaíonn ó fhormhór na leathanach: roghchláir a dhúnann le cliceáil lasmuigh agus le Escape, tarraingt an laisc, staid bhrúite iOS, agus roghchlár na scéime dathanna. Lódáil ar dtús é.", |
| "gallery.started.file.light-js": "An solas a léann na stíleanna doimhneachta: scáthanna lasta de réir na huaire, agus loinnir ar an gcnaipe faoin bpointeoir. Scrios é don dearadh socair.", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "An sleamhnán idir leathanaigh ar fhón, do na blaoscanna sidebar agus console. Inscriosta in éineacht le shell.js.", |
| "gallery.started.file.shell-js": "Nascleanúint fóin do na blaoscanna sidebar agus console: an sleamhnán idir leathanaigh, Siar a athúsáideann an stair, agus an fócas ar ais ar an rannán a d'fhág tú. Inscriosta leis féin.", |
| "gallery.started.file.sprite": "Sprite le symbol amháin in aghaidh an deilbhín, do shuíomh a fhreastalaíonn a chóip féin de Fiesta. Ón CDN, cuir an SVG ó icons/<slug>.svg inlíne ina ionad.", |
| - "gallery.started.file.strings": "Na focail a chuireann select.js agus datetime.js ar an scáileán, i {language}. Lódáil an ceann a mheaitseálann lang do leathanaigh.", |
| + "gallery.started.file.strings": "Na focail a chuireann select.js, datetime.js agus copy.js ar an scáileán, i {language}. Lódáil an ceann a mheaitseálann lang do leathanaigh.", |
| "gallery.started.file.theme": "Dath, teaghlach cló agus cruth don téama {theme}: bloc :root amháin ina bhfógraítear gach dath uair amháin mar phéire light-dark().", |
| "gallery.started.file.tokens": "Struchtúr: gach aicme comhpháirte, gach scála, agus gan dath litriúil ar bith. An comhad céanna faoi gach téama.", |
| "gallery.started.lead": "Is stílbhileog amháin agus dornán scripteanna roghnacha é Fiesta. Oibríonn gach rud le scripteanna múchta; is feabhsuithe iad na scripteanna, gach ceann ina chomhad féin, mar sin lódálann tú na cinn a úsáideann do leathanach agus faic eile.", |
diff --git a/internal/gallery/i18n/hi.json b/internal/gallery/i18n/hi.json| index ed85618..c65bb00 100644 |
| --- a/internal/gallery/i18n/hi.json |
| +++ b/internal/gallery/i18n/hi.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "टोकन और {theme} थीम एक ही फ़ाइल में: हर घटक की क्लास, हर स्केल, और रंग, हर एक light-dark() जोड़ी के रूप में एक बार घोषित।", |
| "gallery.started.file.busy-js": "व्यस्त नियम: फ़ॉर्म भेजे जाते समय उसका बटन कम से कम 650ms तक स्पिनर दिखाता है और दूसरी बार भेजने से मना करता है, और वापस जाने पर बटन लौट आता है।", |
| "gallery.started.file.calendar-js": "उस फ़ील्ड का कैलेंडर बटन जो महीने की ग्रिड खोलता है। जब तक datetime.js उससे पैनल न माँगे तब तक निष्क्रिय, और अलग से हटाया जा सकता है: इसके बिना बटन ब्राउज़र के अपने पिकर पर लौट आता है।", |
| + "gallery.started.file.copy-js": "स्निपेट का कॉपी बटन: यह कोड कॉपी करता है, “कॉपी हो गया” कहता है, और जिस ब्राउज़र में क्लिपबोर्ड नहीं है वहाँ छिपा रहता है। अलग से हटाया जा सकता है।", |
| "gallery.started.file.datetime-js": "डेट फ़ील्ड का प्राकृतिक-भाषा इनपुट। जब तक कोई फ़ील्ड data-rst-date या data-rst-time से न चुने तब तक निष्क्रिय, और अलग से हटाया जा सकता है।", |
| "gallery.started.file.fiesta-js": "वे सुधार जो ज़्यादातर पेज चाहते हैं: बाहर क्लिक करने और Escape दबाने पर बंद होने वाले मेन्यू, स्विच को खींचना, iOS की दबी हुई स्थिति, और रंग योजना का मेन्यू। इसे सबसे पहले लोड करें।", |
| "gallery.started.file.light-js": "वह रोशनी जिसे गहराई की शैलियाँ पढ़ती हैं: समय के हिसाब से पड़ती छायाएँ, और पॉइंटर के नीचे वाले बटन पर एक चमक। स्थिर डिज़ाइन के लिए इसे हटा दें।", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "फ़ोन पर पन्नों के बीच की स्लाइड, sidebar और console शेल के लिए। shell.js के साथ हटाया जा सकता है।", |
| "gallery.started.file.shell-js": "sidebar और console शेल के लिए फ़ोन नेविगेशन: पन्नों के बीच की स्लाइड, इतिहास को दोबारा इस्तेमाल करने वाला वापस, और फ़ोकस उसी हिस्से पर लौटना जिसे आपने छोड़ा था। अलग से हटाया जा सकता है।", |
| "gallery.started.file.sprite": "हर आइकन के लिए एक symbol वाला स्प्राइट, उस साइट के लिए जो Fiesta की अपनी प्रति परोसती है। CDN से, इसकी जगह icons/<slug>.svg का SVG इनलाइन करें।", |
| - "gallery.started.file.strings": "वे शब्द जो select.js और datetime.js स्क्रीन पर दिखाते हैं, {language} में। वह फ़ाइल लोड करें जो आपके पेज के lang से मेल खाती है।", |
| + "gallery.started.file.strings": "वे शब्द जो select.js, datetime.js और copy.js स्क्रीन पर दिखाते हैं, {language} में। वह फ़ाइल लोड करें जो आपके पेज के lang से मेल खाती है।", |
| "gallery.started.file.theme": "{theme} थीम के लिए रंग, टाइप फ़ैमिली और आकार: एक :root ब्लॉक, जिसमें हर रंग एक बार light-dark() जोड़े के रूप में घोषित है।", |
| "gallery.started.file.tokens": "संरचना: हर कंपोनेंट क्लास, हर स्केल, और कहीं कोई रंग लिटरल नहीं। हर थीम में वही फ़ाइल।", |
| "gallery.started.lead": "Fiesta एक स्टाइलशीट और मुट्ठी भर वैकल्पिक स्क्रिप्टें है। स्क्रिप्ट बंद होने पर भी सब कुछ काम करता है; स्क्रिप्टें सुधार हैं, हर एक अपनी फ़ाइल में, इसलिए आप वही लोड करते हैं जो आपका पेज इस्तेमाल करता है, और कुछ नहीं।", |
diff --git a/internal/gallery/i18n/ja.json b/internal/gallery/i18n/ja.json| index 47d6dcb..8bce3a4 100644 |
| --- a/internal/gallery/i18n/ja.json |
| +++ b/internal/gallery/i18n/ja.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "トークンと {theme} テーマをひとつのファイルに。すべてのコンポーネントのクラス、すべてのスケール、そして色。色はどれも light-dark() の対として一度だけ宣言されています。", |
| "gallery.started.file.busy-js": "ビジールール。フォームの送信中、ボタンは少なくとも 650ms スピナーを表示し、二度目の送信を拒否します。「戻る」で元に戻ります。", |
| "gallery.started.file.calendar-js": "そのフィールドのカレンダーボタンが開く月のグリッド。datetime.js がパネルを求めるまで何もせず、単独で削除できます。無ければボタンはブラウザ自身のピッカーに戻ります。", |
| + "gallery.started.file.copy-js": "スニペットの「コピー」ボタン。コードをコピーして「コピーしました」と表示し、クリップボードのないブラウザでは隠れたままです。単独で削除できます。", |
| "gallery.started.file.datetime-js": "日付フィールドの自然言語入力。フィールドが data-rst-date か data-rst-time で名乗り出るまで何もせず、単独で削除できます。", |
| "gallery.started.file.fiesta-js": "たいていのページが欲しがる拡張です。外側のクリックや Escape で閉じるメニュー、スイッチのドラッグ、iOS の押下状態、配色メニュー。最初に読み込んでください。", |
| "gallery.started.file.light-js": "奥行きのスタイルが読む光。時刻に応じて照らされる影と、ポインターの下のボタンに乗る光沢です。静止したデザインにするなら削除してください。", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "スマートフォンでのページ間のスライド。sidebar と console シェル用です。shell.js と一緒に削除できます。", |
| "gallery.started.file.shell-js": "sidebar と console シェルのスマートフォン向けナビゲーション。ページ間のスライド、履歴を再利用する「戻る」、離れたセクションへのフォーカスの復帰。単独で削除できます。", |
| "gallery.started.file.sprite": "アイコンごとに symbol をひとつ持つスプライトで、Fiesta を自前で配信するサイト向けです。CDN から使うなら、代わりに icons/<slug>.svg の SVG をインラインで埋め込んでください。", |
| - "gallery.started.file.strings": "select.js と datetime.js が画面に出す言葉の {language} 版です。ページの lang に合うものを読み込んでください。", |
| + "gallery.started.file.strings": "select.js、datetime.js、copy.js が画面に出す言葉の {language} 版です。ページの lang に合うものを読み込んでください。", |
| "gallery.started.file.theme": "{theme} テーマの色、書体、形。:root ブロック一つのなかで、すべての色が light-dark() の対として一度だけ宣言されています。", |
| "gallery.started.file.tokens": "構造。すべてのコンポーネントのクラス、すべてのスケール、そして色のリテラルはひとつもなし。どのテーマでも同じファイルです。", |
| "gallery.started.lead": "Fiesta はスタイルシートひとつと、いくつかの任意のスクリプトです。スクリプトを切ってもすべて動きます。スクリプトは拡張で、それぞれ別のファイルなので、ページが使うものだけを読み込み、ほかは読み込みません。", |
diff --git a/internal/gallery/i18n/pt-PT.json b/internal/gallery/i18n/pt-PT.json| index 3f3a235..c0eb7b7 100644 |
| --- a/internal/gallery/i18n/pt-PT.json |
| +++ b/internal/gallery/i18n/pt-PT.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "Os tokens e o tema {theme} num só ficheiro: cada classe de componente, cada escala e as cores, cada uma declarada uma vez como par light-dark().", |
| "gallery.started.file.busy-js": "A regra de ocupado: enquanto um formulário é enviado, o seu botão mostra um indicador de carregamento durante pelo menos 650ms e recusa um segundo envio; ao voltar atrás é reposto.", |
| "gallery.started.file.calendar-js": "A grelha do mês que o botão de calendário desse campo abre. Inerte até o datetime.js lhe pedir um painel, e apagável por si só: sem ela, o botão recorre ao seletor do próprio navegador.", |
| + "gallery.started.file.copy-js": "O botão Copiar do snippet: copia o código, diz Copiado e fica escondido onde o navegador não tem área de transferência. Apagável por si só.", |
| "gallery.started.file.datetime-js": "A entrada em linguagem natural dos campos de data. Inerte até um campo aderir com data-rst-date ou data-rst-time, e apagável por si só.", |
| "gallery.started.file.fiesta-js": "As melhorias que quase todas as páginas querem: menus que fecham com um clique fora e com Escape, o arrastar do interruptor, o estado premido do iOS e o menu de esquema de cores. Carregue-o primeiro.", |
| "gallery.started.file.light-js": "A luz que os estilos de profundidade leem: sombras iluminadas conforme a hora, e um brilho no botão sob o ponteiro. Apague-o para o design parado.", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "O deslize entre páginas no telemóvel, para os invólucros sidebar e console. Apagável juntamente com o shell.js.", |
| "gallery.started.file.shell-js": "A navegação no telemóvel dos invólucros sidebar e console: o deslize entre páginas, um Voltar que reutiliza o histórico e o foco devolvido à secção que deixou. Apagável por si só.", |
| "gallery.started.file.sprite": "Um sprite com um symbol por ícone, para um site que serve a sua própria cópia do Fiesta. A partir da CDN, inclua em vez disso o SVG de icons/<slug>.svg em linha.", |
| - "gallery.started.file.strings": "As palavras que o select.js e o datetime.js põem no ecrã, em {language}. Carregue o que corresponde ao lang da sua página.", |
| + "gallery.started.file.strings": "As palavras que o select.js, o datetime.js e o copy.js põem no ecrã, em {language}. Carregue o que corresponde ao lang da sua página.", |
| "gallery.started.file.theme": "Cor, família tipográfica e forma do tema {theme}: um único bloco :root onde cada cor é declarada uma vez como par light-dark().", |
| "gallery.started.file.tokens": "Estrutura: todas as classes de componentes, todas as escalas e nenhum literal de cor. O mesmo ficheiro em qualquer tema.", |
| "gallery.started.lead": "O Fiesta é uma folha de estilos e um punhado de scripts opcionais. Tudo funciona sem scripts; os scripts são melhorias, cada um no seu ficheiro, por isso carrega os que a sua página usa e mais nada.", |
diff --git a/internal/gallery/i18n/ru.json b/internal/gallery/i18n/ru.json| index 980c152..baf18ac 100644 |
| --- a/internal/gallery/i18n/ru.json |
| +++ b/internal/gallery/i18n/ru.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "Токены и тема {theme} в одном файле: каждый класс компонентов, каждая шкала и цвета, каждый объявлен один раз парой light-dark().", |
| "gallery.started.file.busy-js": "Правило занятости: пока форма отправляется, её кнопка показывает индикатор не меньше 650ms и не даёт отправить второй раз, а «Назад» возвращает её как было.", |
| "gallery.started.file.calendar-js": "Сетка месяца, которую открывает кнопка календаря этого поля. Бездействует, пока datetime.js не запросит панель; удаляется отдельно: без неё кнопка возвращается к собственному выбору браузера.", |
| + "gallery.started.file.copy-js": "Кнопка «Копировать» у сниппета: копирует код, пишет «Скопировано» и остаётся скрытой там, где у браузера нет буфера обмена. Удаляется отдельно.", |
| "gallery.started.file.datetime-js": "Ввод дат на естественном языке. Бездействует, пока поле не подключится через data-rst-date или data-rst-time; удаляется отдельно.", |
| "gallery.started.file.fiesta-js": "Улучшения, которые нужны большинству страниц: меню, закрывающиеся по щелчку снаружи и по Escape, перетаскивание переключателя, нажатое состояние в iOS и меню цветовой схемы. Загружайте его первым.", |
| "gallery.started.file.light-js": "Свет, который читают стили глубины: тени, освещение которых зависит от времени суток, и блик на кнопке под указателем. Удалите его ради неподвижного дизайна.", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "Сдвиг между страницами на телефоне, для оболочек sidebar и console. Удаляется вместе с shell.js.", |
| "gallery.started.file.shell-js": "Навигация на телефоне для оболочек sidebar и console: сдвиг между страницами, «Назад», который использует историю, и фокус, возвращённый на раздел, откуда вы ушли. Удаляется отдельно.", |
| "gallery.started.file.sprite": "Спрайт с одним symbol на каждую иконку — для сайта, который отдаёт свою копию Fiesta. С CDN вместо этого встраивайте SVG из icons/<slug>.svg.", |
| - "gallery.started.file.strings": "Слова, которые select.js и datetime.js выводят на экран, на языке {language}. Загружайте тот файл, что совпадает с lang вашей страницы.", |
| + "gallery.started.file.strings": "Слова, которые select.js, datetime.js и copy.js выводят на экран, на языке {language}. Загружайте тот файл, что совпадает с lang вашей страницы.", |
| "gallery.started.file.theme": "Цвет, гарнитура и форма для темы {theme}: один блок :root, где каждый цвет объявлен один раз парой light-dark().", |
| "gallery.started.file.tokens": "Структура: все классы компонентов, все шкалы и ни одного цветового литерала. Один и тот же файл при любой теме.", |
| "gallery.started.lead": "Fiesta — это одна таблица стилей и горстка необязательных скриптов. Всё работает с выключенными скриптами; скрипты — улучшения, каждый в своём файле, так что вы загружаете те, что использует страница, и ничего больше.", |
diff --git a/internal/gallery/i18n/vi.json b/internal/gallery/i18n/vi.json| index b72ce1b..8d450b0 100644 |
| --- a/internal/gallery/i18n/vi.json |
| +++ b/internal/gallery/i18n/vi.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "Token và chủ đề {theme} trong một tệp: mọi lớp thành phần, mọi thang, và các màu, mỗi màu khai báo một lần dưới dạng cặp light-dark().", |
| "gallery.started.file.busy-js": "Quy tắc bận: trong lúc biểu mẫu đang gửi, nút của nó hiện vòng xoay ít nhất 650ms và từ chối lần gửi thứ hai, còn Quay lại trả nó về như cũ.", |
| "gallery.started.file.calendar-js": "Lưới tháng mà nút lịch của trường đó mở ra. Nằm im cho tới khi datetime.js hỏi xin một bảng, và xoá riêng được: thiếu nó, nút quay về bộ chọn của chính trình duyệt.", |
| + "gallery.started.file.copy-js": "Nút Sao chép của snippet: sao chép đoạn mã, báo Đã sao chép, và vẫn ẩn ở trình duyệt không có bộ nhớ tạm. Xoá riêng được.", |
| "gallery.started.file.datetime-js": "Nhập ngày bằng ngôn ngữ tự nhiên. Nằm im cho tới khi một trường đăng ký bằng data-rst-date hoặc data-rst-time, và xoá riêng được.", |
| "gallery.started.file.fiesta-js": "Những nâng cấp mà hầu hết các trang cần: menu đóng khi bấm ra ngoài và khi nhấn Escape, kéo công tắc, trạng thái nhấn của iOS, và menu bảng màu. Hãy tải nó trước tiên.", |
| "gallery.started.file.light-js": "Ánh sáng mà các kiểu chiều sâu đọc: bóng đổ theo giờ, và một lớp ánh trên nút dưới con trỏ. Xoá nó để có thiết kế tĩnh.", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "Hiệu ứng trượt giữa các trang trên điện thoại, cho shell sidebar và console. Xoá được cùng với shell.js.", |
| "gallery.started.file.shell-js": "Điều hướng trên điện thoại cho shell sidebar và console: hiệu ứng trượt giữa các trang, nút Quay lại dùng lại lịch sử, và tiêu điểm trở về đúng mục bạn vừa rời. Xoá riêng được.", |
| "gallery.started.file.sprite": "Một sprite với một symbol cho mỗi biểu tượng, dành cho trang tự phục vụ bản sao Fiesta của mình. Nếu dùng từ CDN, hãy nhúng nội tuyến SVG từ icons/<slug>.svg thay vào đó.", |
| - "gallery.started.file.strings": "Những từ mà select.js và datetime.js hiển thị trên màn hình, bằng {language}. Hãy tải tệp khớp với lang của trang bạn.", |
| + "gallery.started.file.strings": "Những từ mà select.js, datetime.js và copy.js hiển thị trên màn hình, bằng {language}. Hãy tải tệp khớp với lang của trang bạn.", |
| "gallery.started.file.theme": "Màu sắc, họ chữ và hình khối cho chủ đề {theme}: một khối :root duy nhất, mỗi màu khai báo một lần dưới dạng cặp light-dark().", |
| "gallery.started.file.tokens": "Cấu trúc: mọi lớp component, mọi thang đo, và không một giá trị màu nào. Cùng một tệp dưới mọi chủ đề.", |
| "gallery.started.lead": "Fiesta là một tệp định kiểu và một ít script tuỳ chọn. Mọi thứ đều chạy khi tắt script; các script là phần nâng cấp, mỗi cái một tệp riêng, nên bạn chỉ tải những cái trang của bạn dùng và không gì khác.", |
diff --git a/internal/gallery/i18n/yue.json b/internal/gallery/i18n/yue.json| index 0a181ad..ce681cd 100644 |
| --- a/internal/gallery/i18n/yue.json |
| +++ b/internal/gallery/i18n/yue.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "設計變數同 {theme} 主題放喺同一個檔案:每個組件類別、每個比例尺,同埋啲顏色,每隻色都以 light-dark() 一對宣告一次。", |
| "gallery.started.file.busy-js": "忙碌規則:表格提交緊嗰陣,個掣會顯示轉圈最少 650ms,拒絕第二次提交,撳返轉頭就會還原。", |
| "gallery.started.file.calendar-js": "嗰個欄位嘅日曆掣打開嘅月曆格。喺 datetime.js 問佢攞面板之前都係唔郁嘅,可以單獨刪走:冇咗佢,個掣就退返去瀏覽器自己嘅揀選器。", |
| + "gallery.started.file.copy-js": "snippet 嘅「複製」掣:複製段代碼,顯示「複製咗」,喺冇剪貼簿嘅瀏覽器就一直收埋。可以單獨刪走。", |
| "gallery.started.file.datetime-js": "日期欄位嘅自然語言輸入。喺某個欄位用 data-rst-date 或 data-rst-time 啟用之前都係唔郁嘅,可以單獨刪走。", |
| "gallery.started.file.fiesta-js": "大部分頁面都想要嘅增強:喺外面撳一下或者撳 Escape 就會閂嘅選單、開關嘅拖動、iOS 嘅撳住狀態,同配色選單。最先載入佢。", |
| "gallery.started.file.light-js": "層次樣式讀取嘅光:隨時間照出嘅陰影,同埋指針下面嘅按鈕上嘅光澤。想要靜止設計就刪走佢。", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "手機上頁面之間嘅滑動,俾 sidebar 同 console 外殼用。可以同 shell.js 一齊刪走。", |
| "gallery.started.file.shell-js": "sidebar 同 console 外殼嘅手機導航:頁面之間嘅滑動、重用瀏覽紀錄嘅「返回」,同埋將焦點還返去你離開嗰個部分。可以單獨刪走。", |
| "gallery.started.file.sprite": "每個圖示一個 symbol 嘅 sprite,畀自己提供 Fiesta 副本嘅網站用。如果用 CDN,就改為內嵌 icons/<slug>.svg 嘅 SVG。", |
| - "gallery.started.file.strings": "select.js 同 datetime.js 喺畫面上顯示嘅字,{language} 版。載入同你頁面 lang 吻合嗰個。", |
| + "gallery.started.file.strings": "select.js、datetime.js 同 copy.js 喺畫面上顯示嘅字,{language} 版。載入同你頁面 lang 吻合嗰個。", |
| "gallery.started.file.theme": "{theme} 主題嘅顏色、字體同形狀:一個 :root 區塊,每隻色以 light-dark() 成對聲明一次。", |
| "gallery.started.file.tokens": "結構:所有組件嘅類、所有比例,冇任何顏色字面值。喺每個主題之下都係同一個檔案。", |
| "gallery.started.lead": "Fiesta 係一個樣式表加幾個可選嘅腳本。熄咗腳本樣樣都照用得;腳本係增強,每個都係獨立檔案,所以你淨係載入頁面用到嗰啲,其他唔使。", |
diff --git a/internal/gallery/i18n/zh-Hans.json b/internal/gallery/i18n/zh-Hans.json| index ce9e914..7882375 100644 |
| --- a/internal/gallery/i18n/zh-Hans.json |
| +++ b/internal/gallery/i18n/zh-Hans.json |
| @@ -209,6 +209,7 @@ |
| "gallery.started.file.bundle": "设计变量和 {theme} 主题放在一个文件里:每个组件类、每个比例尺,以及颜色,每种颜色都以 light-dark() 一对声明一次。", |
| "gallery.started.file.busy-js": "忙碌规则:表单提交期间,按钮至少显示 650ms 的转圈并拒绝第二次提交,返回时恢复原样。", |
| "gallery.started.file.calendar-js": "该字段的日历按钮打开的月份网格。在 datetime.js 向它索取面板之前是惰性的,也可以单独删除:没有它,按钮就回退到浏览器自带的选择器。", |
| + "gallery.started.file.copy-js": "代码片段的“复制”按钮:复制代码,显示“已复制”,在没有剪贴板的浏览器里保持隐藏。可以单独删除。", |
| "gallery.started.file.datetime-js": "日期字段的自然语言输入。在某个字段用 data-rst-date 或 data-rst-time 主动启用之前是惰性的,也可以单独删除。", |
| "gallery.started.file.fiesta-js": "大多数页面都需要的增强:在外面点击或按 Escape 就会关闭的菜单、开关的拖动、iOS 的按下状态,以及配色菜单。最先加载它。", |
| "gallery.started.file.light-js": "深度样式读取的光:随时刻照出的阴影,以及指针下的按钮上的一层光泽。删除它即可得到静止的设计。", |
| @@ -216,7 +217,7 @@ |
| "gallery.started.file.shell-css": "手机上页面之间的滑动切换,供 sidebar 和 console 外壳使用。可与 shell.js 一起删除。", |
| "gallery.started.file.shell-js": "sidebar 和 console 外壳的手机导航:页面之间的滑动切换、复用浏览历史的“返回”,以及把焦点还给你离开的那个栏目。可以单独删除。", |
| "gallery.started.file.sprite": "每个图标一个 symbol 的 sprite,供自己提供 Fiesta 副本的站点使用。如果用 CDN,就改为内联 icons/<slug>.svg 里的 SVG。", |
| - "gallery.started.file.strings": "select.js 和 datetime.js 显示在屏幕上的文字,{language} 版。加载与你页面的 lang 相符的那个。", |
| + "gallery.started.file.strings": "select.js、datetime.js 和 copy.js 显示在屏幕上的文字,{language} 版。加载与你页面的 lang 相符的那个。", |
| "gallery.started.file.theme": "{theme} 主题的颜色、字体族和形状:一个 :root 块,每个颜色以 light-dark() 成对声明一次。", |
| "gallery.started.file.tokens": "结构:所有组件类、所有比例,没有任何颜色字面量。在每个主题下都是同一个文件。", |
| "gallery.started.lead": "Fiesta 是一个样式表加几个可选的脚本。关掉脚本一切照常工作;脚本是增强,每个都是单独的文件,所以你只加载页面用到的那些,别的不用。", |
diff --git a/internal/gallery/started.go b/internal/gallery/started.go| index d5f7849..7ebdce1 100644 |
| --- a/internal/gallery/started.go |
| +++ b/internal/gallery/started.go |
| @@ -42,9 +42,8 @@ type assetsView struct { |
| // scriptOrder is the order the scripts are listed in: fiesta.js first |
| // and calendar.js last, as the page says to load them, the rest as the |
| // scripts paragraph names them. A script in the release and not here |
| -// fails the render by name, so a ninth script cannot ship off this |
| -// page. |
| -var scriptOrder = []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js"} |
| +// fails the render by name, so a new script cannot ship off this page. |
| +var scriptOrder = []string{"fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "copy.js", "select.js", "datetime.js", "calendar.js"} |
| // blurbs is the copy id of each shipped file whose sentence has no |
| // placeholder. The one-file themes, the themes and the strings take |
| @@ -57,6 +56,7 @@ var blurbs = map[string]string{ |
| "light.js": "gallery.started.file.light-js", |
| "anchor.js": "gallery.started.file.anchor-js", |
| "shell.js": "gallery.started.file.shell-js", |
| + "copy.js": "gallery.started.file.copy-js", |
| "select.js": "gallery.started.file.select-js", |
| "datetime.js": "gallery.started.file.datetime-js", |
| "calendar.js": "gallery.started.file.calendar-js", |
diff --git a/internal/gallery/started_test.go b/internal/gallery/started_test.go| index 0ea79aa..d851330 100644 |
| --- a/internal/gallery/started_test.go |
| +++ b/internal/gallery/started_test.go |
| @@ -32,7 +32,7 @@ func shippedRows(t *testing.T) []string { |
| rows := []string{ |
| "fiesta.css", "fiesta-plain.css", "fiesta-signal.css", |
| "tokens.css", "day.css", "plain.css", "signal.css", "shell.css", |
| - "fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "select.js", "datetime.js", "calendar.js", |
| + "fiesta.js", "busy.js", "light.js", "anchor.js", "shell.js", "copy.js", "select.js", "datetime.js", "calendar.js", |
| "icons.svg", |
| } |
| entries, err := fs.ReadDir(fiesta.FS(), "i18n") |
diff --git a/js/copy.js b/js/copy.js| new file mode 100644 |
| index 0000000..1f570c6 |
| --- /dev/null |
| +++ b/js/copy.js |
| @@ -0,0 +1,171 @@ |
| +/* copy.js: a snippet's Copy button. First-party, dependency-free, |
| + CSP-clean and optional: delete it and its <script> tag and the code |
| + is still on the page to select by hand, because the button is hidden |
| + in the markup and only this file shows it. It is app-owned from the |
| + moment it is scaffolded. |
| + |
| + What it reads, in either spelling: |
| + [rst-snippet], .rst-snippet the box |
| + the box's <code> what is copied, as text |
| + [rst-snippet-copy], .rst-snippet__copy the button, hidden |
| + [rst-snippet-status], .rst-snippet__status a role="status" line |
| + On the button, both optional: |
| + data-rst-copy-done the label, and the status, once it is copied |
| + data-rst-copy-failed the status when the copy fails |
| + Each is looked up on the attribute, then in the page's table |
| + (self.fiestaStrings[lang], which i18n/<lang>.js fills), then the |
| + English written here, as select.js does. |
| + |
| + Where the browser has no clipboard API (an old engine, a page that is |
| + not a secure context) the file stops before it listens for anything |
| + and the buttons stay hidden: a button that does nothing is worse than |
| + no button. |
| + |
| + Each click is a request with a number, and only a button's latest |
| + request may change anything. A click puts the label back and empties |
| + the status at once, and cancels the button's two timers, before it |
| + writes. A write that worked shows the done string on the label for |
| + two seconds; one that failed (a rejected promise or a throw, a denied |
| + permission among them) leaves the label alone and selects the code, |
| + so it can be copied by hand. Either way the status gets its message |
| + 100 ms after the write settles, never at once: a live region that is |
| + written the text it already holds has not changed, and a screen |
| + reader says nothing the second time. Clicks closer together than |
| + that, or a click while an earlier write is still pending, end in one |
| + announcement, the latest request's, and a request that settles after |
| + a newer one never writes at all. */ |
| +(function () { |
| + "use strict"; |
| + |
| + if (window.fiestaCopy) return; |
| + window.fiestaCopy = true; |
| + |
| + // No clipboard API, no button: return before listening for anything, |
| + // so the buttons keep the hidden attribute the markup gave them. |
| + if (typeof navigator.clipboard?.writeText !== "function") return; |
| + |
| + var BOX = "[rst-snippet],.rst-snippet"; |
| + var BUTTON = "[rst-snippet-copy],.rst-snippet__copy"; |
| + var STATUS = "[rst-snippet-status],.rst-snippet__status"; |
| + // Long enough to read the done string, short enough that the button |
| + // is the button again before anyone reaches for it twice. |
| + var SHOWN_MS = 2000; |
| + // The status is emptied at the click and written this long after the |
| + // write settles, so two messages in a row are two changes. |
| + var SPEAK_MS = 100; |
| + |
| + // The strings: the attribute, then the page's table, then the |
| + // fallback (the header says why). select.js carries the same two |
| + // functions, so this file stays one script tag; |
| + // check/scripts_test.go holds the copies identical. |
| + |
| + // The table for a tag: an exact match whatever its case, else the |
| + // first in its primary language (pt-BR reads pt-PT), else null. |
| + function fiestaPickLang(tag, registry) { |
| + var want = String(tag || "").toLowerCase(); |
| + if (!want) return null; |
| + var keys = Object.keys(registry || {}), i; |
| + for (i = 0; i < keys.length; i++) { |
| + if (keys[i].toLowerCase() === want) return keys[i]; |
| + } |
| + var primary = want.split("-")[0]; |
| + for (i = 0; i < keys.length; i++) { |
| + if (keys[i].toLowerCase().split("-")[0] === primary) return keys[i]; |
| + } |
| + return null; |
| + } |
| + |
| + function fiestaWord(el, key, fallback) { |
| + var v = el.getAttribute("data-rst-" + key); |
| + if (v !== null && v !== "") return v; |
| + var registry = (typeof self !== "undefined" && self.fiestaStrings) || {}; |
| + var lang = fiestaPickLang(document.documentElement.lang, registry); |
| + var table = lang ? registry[lang] : null; |
| + return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback; |
| + } |
| + |
| + // Per button: the label it was given, the number of its latest |
| + // request, and its two timers. A WeakMap, so a button the page |
| + // removes takes its state with it. |
| + var buttons = new WeakMap(); |
| + function state(button) { |
| + var s = buttons.get(button); |
| + if (!s) { |
| + s = { label: button.textContent, latest: 0, reset: 0, speak: 0 }; |
| + buttons.set(button, s); |
| + } |
| + return s; |
| + } |
| + |
| + // Selecting the code is what a failed copy leaves the reader with: |
| + // the text is chosen, and their own copy takes it. |
| + function select(code) { |
| + var range = document.createRange(), selection = window.getSelection(); |
| + range.selectNodeContents(code); |
| + selection.removeAllRanges(); |
| + selection.addRange(range); |
| + } |
| + |
| + // The message goes in SPEAK_MS after a settlement, and only if the |
| + // request is still the button's latest when the timer fires: a click |
| + // in between has cancelled the timer already, and the number is the |
| + // check that holds if a callback runs anyway. |
| + function say(s, n, status, text) { |
| + if (!status) return; |
| + s.speak = setTimeout(function () { |
| + if (n === s.latest) status.textContent = text; |
| + }, SPEAK_MS); |
| + } |
| + |
| + function copy(button, box, code) { |
| + var s = state(button), n = ++s.latest, status = box.querySelector(STATUS), write; |
| + // Back to the start before this request writes, so nothing an |
| + // earlier one left survives it: not its label, not its message, |
| + // not a timer it has yet to fire. |
| + clearTimeout(s.reset); |
| + clearTimeout(s.speak); |
| + button.textContent = s.label; |
| + if (status) status.textContent = ""; |
| + // A throw (a browser that refuses outright) fails like a rejection. |
| + try { |
| + write = Promise.resolve(navigator.clipboard.writeText(code.textContent)); |
| + } catch (err) { |
| + write = Promise.reject(err); |
| + } |
| + write.then(function () { |
| + if (n !== s.latest) return; |
| + var done = fiestaWord(button, "copy-done", "Copied"); |
| + button.textContent = done; |
| + s.reset = setTimeout(function () { |
| + if (n === s.latest) button.textContent = s.label; |
| + }, SHOWN_MS); |
| + say(s, n, status, done); |
| + }, function () { |
| + if (n !== s.latest) return; |
| + select(code); |
| + say(s, n, status, fiestaWord(button, "copy-failed", "Copy failed. Select the code and copy it yourself.")); |
| + }); |
| + } |
| + |
| + // Shown once the document is parsed, each remembering its label |
| + // first, while it still reads as the markup wrote it. |
| + function show() { |
| + document.querySelectorAll(BUTTON).forEach(function (button) { |
| + state(button); |
| + button.hidden = false; |
| + }); |
| + } |
| + if (document.readyState === "loading") { |
| + document.addEventListener("DOMContentLoaded", show); |
| + } else { |
| + show(); |
| + } |
| + |
| + // One listener for every snippet on the page, however many. |
| + document.addEventListener("click", function (e) { |
| + var button = e.target && e.target.closest ? e.target.closest(BUTTON) : null; |
| + var box = button && button.closest(BOX); |
| + var code = box && box.querySelector("code"); |
| + if (code) copy(button, box, code); |
| + }); |
| +})(); |