rastrillo / fiesta Public

The boxes keep a native control's disabled and readonly state, repaint on a form reset, and read English with no table

Astra's review of the release found that the enhanced controls did
not keep the native control's state. A disabled select got an enabled
search box whose handler changed the hidden select; an option under a
disabled optgroup could be chosen; a readonly date field could be
changed through commit, the quick picks and the grid. And a form reset
put each native control back to its default without a change event,
so the box, whose own default is empty, went blank while the form
posted the default.

select.js now sets the box's disabled state from the select's and
follows it in the flips observer, closing an open list; choose refuses
a row while the select matches :disabled, which a disabled fieldset
sets without an attribute; and offLimits reads an option's disabled
state through its optgroup. datetime.js sets the box's disabled and
readonly state and the button's from the field's, follows the two
attributes with an observer, and refuses commit, commitText and every
popup while the field is readonly or matches :disabled. Both listen for
reset on the document, read the control's form at that moment, and
repaint a task later unless a listener cancelled it. The select comment
that said a reset dispatches change says what a reset does instead.

The README promised an English page tomorrow, next fri 9am and in 2
weeks with no table loaded; the labels fell back to English and the
vocabulary fell back to nothing. ENGLISH_WORDS is i18n/en.json's
date-words, vocabulary() resolves attribute, then table, then English,
and TestTheEnglishVocabularyIsTheFallback holds the words to the table
and parses the three phrases through node/vocab_node.mjs.

Five drives try every way of changing each control and read what the
form posts: a disabled select, in and out of a fieldset; an option
under a disabled group; a readonly, a disabled and a fieldset-disabled
date field; and a reset of each, through form.reset(), a reset button
and a cancelled reset. Each is red against the previous commit. The
select.js cap moves to 59KB and datetime.js's to 81KB, the arithmetic
in each test; the release is rebuilt as a candidate.
Human

Prompt

The final review's two Important findings and Minor 3: enhanced controls that ignore disabled and readonly, boxes left blank by a form reset, and an English fallback that covered the labels but not the date vocabulary.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

select.js: the box takes the select's disabled state and follows it, refuses every row while the select or its fieldset is disabled, and treats an option under a disabled optgroup as disabled. datetime.js: a readonly field refuses every way of changing it and a disabled one is disabled throughout, button included; the vocabulary falls back to i18n/en.json's date-words, exported as vocabulary(). Both scripts repaint after an uncancelled form reset, a task later. Five browser drives, one Node test, two cap raises (select 59KB, datetime 81KB) and the regenerated 1.0.0 candidate.
Agent notes
The three script changes and the one regenerated releases/1.0.0/ are one commit on purpose: TestAnUntaggedReleaseIsAFreshDist holds the release to the tree at every commit, so a release regenerated once cannot be split across two. Red runs against ac73ca5 are in the final-fix report.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3890194881e6917db030ced7220748fd00a42616 parent ac73ca5
11 files changed, +854 −34
  • check/datetime_browser_test.go +295 −0
  • check/datetime_test.go +76 −1
  • check/node/vocab_node.mjs +56 −0
  • check/scripts_test.go +8 −1
  • check/select_browser_test.go +246 −0
  • check/select_test.go +2 −2
  • js/datetime.js +67 −8
  • js/select.js +40 −7
  • releases/1.0.0/SHA256SUMS +2 −2
  • releases/1.0.0/datetime.js +42 −7
  • releases/1.0.0/select.js +20 −6
diff --git a/check/datetime_browser_test.go b/check/datetime_browser_test.go
new file mode 100644
index 0000000..b3e15ac
--- /dev/null
+++ b/check/datetime_browser_test.go
@@ -0,0 +1,295 @@
+//go:build browser
+
+package check
+
+// Browser drives for datetime.js's native state: a field the page
+// cannot change is a box that cannot change it, and a form reset puts
+// the box back to what the field holds. Each drive runs its steps
+// inside one page script and reads the result back as JSON, like the
+// select drives, since what is fenced is the field's value after each
+// way of changing it was tried.
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/chromedp"
+)
+
+// datePages is one page holding four armed date fields in a form:
+// one readonly, one disabled, one plain, and one inside a fieldset,
+// each with a value, so a change shows and a reset has somewhere to
+// go back to. sizingDoc links calendar.js, so the pick button opens
+// the grid.
+func datePages(t *testing.T) map[string]string {
+ t.Helper()
+ field := func(id, extra string) string {
+ return `<div rst-field><label rst-field-label for="` + id + `">` + id + `</label>` +
+ `<input rst-input id="` + id + `" name="` + id + `" type="date" value="2026-10-20" data-rst-date` + extra + `></div>`
+ }
+ body := `<form method="post" action="/submit">` +
+ field("ro", " readonly") + field("dis", " disabled") + field("plain", "") +
+ `<fieldset id="fs">` + field("inset", "") + `</fieldset>` +
+ `<button type="submit">Save</button></form>`
+ return map[string]string{"/": sizingDoc("datetime", body)}
+}
+
+// driveDate opens datePages on a rig of its own and runs one page
+// script, decoding its result.
+func driveDate(t *testing.T, script string, out any) {
+ t.Helper()
+ rig := sizingRig(t, false, datePages(t))
+ ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
+ defer cancel()
+ var raw string
+ if err := chromedp.Run(ctx,
+ emulation.SetFocusEmulationEnabled(true),
+ chromedp.Navigate(rig.Origin+"/"),
+ chromedp.WaitVisible(`#plain-combo`, chromedp.ByQuery),
+ chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+script+`})()))()`, &raw, awaitPromise),
+ ); err != nil {
+ t.Fatalf("drive failed: %v", err)
+ }
+ if err := json.Unmarshal([]byte(raw), out); err != nil {
+ t.Fatalf("decode %q: %v", raw, err)
+ }
+}
+
+// dateMoves is the page-side helper the drives share: every way of
+// changing a field through its box. Words and Enter commit the text;
+// the suggestions list, if it opened, has a row to press; the pick
+// button opens the grid, whose first day cell is picked; and leaving
+// the box commits whatever it holds.
+const dateMoves = `
+ const frame = () => ` + afterFrame + `;
+ const tick = () => new Promise((r) => setTimeout(r, 0));
+ const form = document.querySelector('form');
+ const posted = (name) => new FormData(form).get(name);
+ const field = (id) => ({
+ native: document.querySelector('#' + id),
+ input: document.querySelector('#' + id + '-combo'),
+ list: document.querySelector('#' + id + '-combo-listbox') || document.querySelector('#' + id + '-listbox'),
+ pick: document.querySelector('#' + id).closest('[rst-dtp]').querySelector('[rst-dtp-pick]'),
+ });
+ const tryAll = async (f) => {
+ const tried = {};
+ f.input.focus();
+ f.input.click();
+ await frame();
+ tried.listOpen = !!(f.list && !f.list.hidden);
+ f.input.value = '25 Dec 2026';
+ f.input.dispatchEvent(new Event('input', { bubbles: true }));
+ f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ await frame();
+ tried.afterEnter = f.native.value;
+ // A quick pick's row, if the list built any.
+ const row = f.list && f.list.querySelector('[rst-dtp-quick]');
+ if (row) row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ await frame();
+ tried.afterRow = f.native.value;
+ // The calendar: press the button, pick the first day shown.
+ f.pick.click();
+ await frame();
+ // This field's own grid, and only while it is shown: a grid
+ // opened and closed earlier stays in the page, hidden.
+ const dialog = f.native.closest('[rst-dtp]').querySelector('[role="dialog"]');
+ tried.calendarOpen = !!dialog && !dialog.hidden && dialog.getClientRects().length > 0;
+ const day = tried.calendarOpen ? dialog.querySelector('[role="gridcell"] button, [role="gridcell"]') : null;
+ if (day) { day.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); day.click(); }
+ await frame();
+ tried.afterCalendar = f.native.value;
+ f.input.value = '25 Dec 2026';
+ f.input.blur();
+ document.body.focus();
+ await tick();
+ await frame();
+ tried.afterBlur = f.native.value;
+ tried.shown = f.input.value;
+ return tried;
+ };
+`
+
+// A readonly date field shows its value and refuses every way of
+// changing it: the list does not open, words and Enter, a quick pick,
+// the grid and leaving the box all leave the field as it was, and the
+// form posts the value it loaded with. Made writable, the same moves
+// change it, so the refusals were the state's. A disabled field is
+// disabled throughout, box and button, and a field in a disabled
+// fieldset, which sets no attribute, is refused at the commit.
+func TestAReadonlyOrDisabledDateFieldCannotBeChangedThroughTheBox(t *testing.T) {
+ t.Parallel()
+ type tried struct {
+ ListOpen bool `json:"listOpen"`
+ AfterEnter string `json:"afterEnter"`
+ AfterRow string `json:"afterRow"`
+ CalendarOpen bool `json:"calendarOpen"`
+ AfterCalendar string `json:"afterCalendar"`
+ AfterBlur string `json:"afterBlur"`
+ Shown string `json:"shown"`
+ }
+ var got struct {
+ RoBox bool `json:"roBox"`
+ RoPick bool `json:"roPick"`
+ Ro tried `json:"ro"`
+ RoPosted string `json:"roPosted"`
+ RoShownLoad string `json:"roShownLoad"`
+ Writable tried `json:"writable"`
+ DisBox bool `json:"disBox"`
+ DisPick bool `json:"disPick"`
+ Dis tried `json:"dis"`
+ DisEnabled string `json:"disEnabled"`
+ InSet bool `json:"inSet"`
+ Set tried `json:"set"`
+ SetPosted string `json:"setPosted"`
+ }
+ driveDate(t, dateMoves+`
+ const out = {};
+ const ro = field('ro');
+ out.roShownLoad = ro.input.value;
+ out.roBox = ro.input.readOnly;
+ out.roPick = ro.pick.disabled;
+ out.ro = await tryAll(ro);
+ out.roPosted = posted('ro');
+ ro.native.readOnly = false;
+ await frame();
+ out.writable = await tryAll(ro);
+ const dis = field('dis');
+ out.disBox = dis.input.disabled;
+ out.disPick = dis.pick.disabled;
+ out.dis = await tryAll(dis);
+ dis.native.disabled = false;
+ await frame();
+ out.disEnabled = dis.input.disabled ? 'still disabled' : 'enabled';
+ const fs = document.querySelector('#fs');
+ fs.disabled = true;
+ await frame();
+ const inset = field('inset');
+ out.inSet = inset.input.matches(':disabled');
+ out.set = await tryAll(inset);
+ fs.disabled = false;
+ await frame();
+ out.setPosted = posted('inset');
+ return out;
+ `, &got)
+ unchanged := func(name string, tr tried) {
+ t.Helper()
+ if tr.ListOpen || tr.CalendarOpen {
+ t.Errorf("%s: a popup opened (list %v, calendar %v) on a field that cannot change", name, tr.ListOpen, tr.CalendarOpen)
+ }
+ for step, v := range map[string]string{"Enter": tr.AfterEnter, "a row": tr.AfterRow, "the calendar": tr.AfterCalendar, "leaving the box": tr.AfterBlur} {
+ if v != "2026-10-20" {
+ t.Errorf("%s: after %s the field is %q, want 2026-10-20", name, step, v)
+ }
+ }
+ }
+ if !got.RoBox || !got.RoPick {
+ t.Errorf("readonly: box readOnly %v, pick disabled %v; want both", got.RoBox, got.RoPick)
+ }
+ unchanged("readonly", got.Ro)
+ if got.Ro.Shown != got.RoShownLoad || got.RoShownLoad == "" {
+ t.Errorf("readonly: the box shows %q after the tries, want what it showed on load, %q", got.Ro.Shown, got.RoShownLoad)
+ }
+ if got.RoPosted != "2026-10-20" {
+ t.Errorf("readonly: the form posts %q, want 2026-10-20", got.RoPosted)
+ }
+ if got.Writable.AfterEnter != "2026-12-25" {
+ t.Errorf("made writable, Enter left the field at %q, want 2026-12-25: the readonly refusals were not the state's", got.Writable.AfterEnter)
+ }
+ if !got.DisBox || !got.DisPick {
+ t.Errorf("disabled: box disabled %v, pick disabled %v; want both", got.DisBox, got.DisPick)
+ }
+ unchanged("disabled", got.Dis)
+ if got.DisEnabled != "enabled" {
+ t.Error("the field was enabled again and its box stayed disabled")
+ }
+ if !got.InSet {
+ t.Fatal("inside a disabled fieldset, the box does not read as :disabled; the fieldset leg is not set up")
+ }
+ unchanged("in a disabled fieldset", got.Set)
+ if got.SetPosted != "2026-10-20" {
+ t.Errorf("in a disabled fieldset: once enabled the form posts %q, want 2026-10-20", got.SetPosted)
+ }
+}
+
+// A form reset puts the field back to its initial value without a
+// change event, and the box, whose own default is empty, resets to
+// blank: the page showed nothing while the form posted a date. After
+// an uncancelled reset, through form.reset() or a reset button, the
+// box shows the field's value again and an open popup closes; a reset
+// a listener cancelled leaves the new date in place.
+func TestAFormResetRepaintsTheDateBox(t *testing.T) {
+ t.Parallel()
+ var got struct {
+ ShownLoad string `json:"shownLoad"`
+ Changed string `json:"changed"`
+ ShownChanged string `json:"shownChanged"`
+ ListReset bool `json:"listReset"`
+ ShownReset string `json:"shownReset"`
+ PostedReset string `json:"postedReset"`
+ ShownButton string `json:"shownButton"`
+ PostedButton string `json:"postedButton"`
+ ShownKept string `json:"shownKept"`
+ PostedKept string `json:"postedKept"`
+ }
+ driveDate(t, dateMoves+`
+ const out = {};
+ const f = field('plain');
+ out.shownLoad = f.input.value;
+ const type = (words) => {
+ f.input.focus();
+ f.input.value = words;
+ f.input.dispatchEvent(new Event('input', { bubbles: true }));
+ f.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ };
+ type('25 Dec 2026');
+ await frame();
+ out.changed = f.native.value;
+ out.shownChanged = f.input.value;
+ // Open the list, then reset through the API.
+ f.input.click();
+ await frame();
+ form.reset();
+ await tick();
+ await frame();
+ out.listReset = !!(f.list && !f.list.hidden);
+ out.shownReset = f.input.value;
+ out.postedReset = posted('plain');
+ f.input.blur();
+ // Again through a reset button.
+ type('25 Dec 2026');
+ await frame();
+ const button = document.createElement('button');
+ button.type = 'reset';
+ form.append(button);
+ button.click();
+ await tick();
+ await frame();
+ out.shownButton = f.input.value;
+ out.postedButton = posted('plain');
+ // A cancelled reset changes nothing.
+ type('25 Dec 2026');
+ await frame();
+ form.addEventListener('reset', (e) => e.preventDefault(), { once: true });
+ form.reset();
+ await tick();
+ await frame();
+ out.shownKept = f.input.value;
+ out.postedKept = posted('plain');
+ return out;
+ `, &got)
+ if got.ShownLoad == "" || got.Changed != "2026-12-25" || got.ShownChanged == got.ShownLoad {
+ t.Fatalf("setup: the box showed %q on load, then %q after typing 25 Dec 2026, and the field is %q; the drive's setup did not take", got.ShownLoad, got.ShownChanged, got.Changed)
+ }
+ if got.ListReset || got.ShownReset != got.ShownLoad || got.PostedReset != "2026-10-20" {
+ t.Errorf("after form.reset(): list open %v, box shows %q, form posts %q; want closed, %q, 2026-10-20", got.ListReset, got.ShownReset, got.PostedReset, got.ShownLoad)
+ }
+ if got.ShownButton != got.ShownLoad || got.PostedButton != "2026-10-20" {
+ t.Errorf("after a reset button: box shows %q, form posts %q; want %q, 2026-10-20", got.ShownButton, got.PostedButton, got.ShownLoad)
+ }
+ if got.ShownKept != got.ShownChanged || got.PostedKept != "2026-12-25" {
+ t.Errorf("after a cancelled reset: box shows %q, form posts %q; want %q, 2026-12-25", got.ShownKept, got.PostedKept, got.ShownChanged)
+ }
+}
diff --git a/check/datetime_test.go b/check/datetime_test.go
index ede4271..561536c 100644
--- a/check/datetime_test.go
+++ b/check/datetime_test.go
@@ -3,11 +3,13 @@ package check
import (
"bytes"
"encoding/json"
+ "maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
+ "time"
"amadan.net/rastrillo/fiesta"
"amadan.net/rastrillo/fiesta/internal/nodetest"
@@ -455,7 +457,18 @@ func TestDatetimeContract(t *testing.T) {
// and the section comment. 78KB is the next whole step that holds
// it, with 0.4KB to spare. The copy in select.js is the same bytes;
// see TestTheTwoLookupsAreTheSame.
- if n := len(raw); n > 78*1024 {
+ //
+ // 78KB → 81KB (measured 82,376 bytes, +2.5KB), by three things the
+ // release review asked for: the English vocabulary, ENGLISH_WORDS
+ // and vocabulary(), so the three phrases the README advertises
+ // parse with no table loaded (about 0.9KB, half of it the words
+ // themselves); the native state, locked() and follow(), so a
+ // readonly field refuses every way of changing it and a disabled
+ // one is disabled throughout (about 1.0KB with its guards); and the
+ // form-reset repaint (about 0.6KB, most of it the comment saying
+ // why it waits a task). 80KB (81,920) does not hold it; 81KB
+ // (82,944) does, with 568 to spare.
+ if n := len(raw); n > 81*1024 {
t.Fatalf("datetime.js is %d bytes; split something out before it stops being readable", n)
}
}
@@ -480,3 +493,65 @@ func TestDatetimeDerivesItsCalendarNames(t *testing.T) {
}
}
}
+
+// The README promises an English page three phrases with no table
+// loaded and no attribute: "tomorrow", "next fri 9am" and "in 2 weeks".
+// The labels always had an English fallback written into the script;
+// the vocabulary fell back to nothing, so all three read as nothing.
+// node/vocab_node.mjs resolves a bare field's vocabulary on a lang="en"
+// page with no self.fiestaStrings and parses each phrase with it. The
+// words must be i18n/en.json's date-words exactly, so a word improved
+// in the table is improved in the script, and every phrase must read:
+// tomorrow is the 29th, two weeks on is 11 November, and next Friday
+// at nine is a Friday at 09:00.
+func TestTheEnglishVocabularyIsTheFallback(t *testing.T) {
+ nodetest.Node(t)
+ phrases := []string{"tomorrow", "next fri 9am", "in 2 weeks"}
+ in, err := json.Marshal(map[string]any{"phrases": phrases})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := nodetest.Run(t, nodetest.Cmd{Args: []string{"node/vocab_node.mjs"}, Stdin: in, Env: []string{"TZ=UTC"}})
+ var got struct {
+ Words map[string]string `json:"words"`
+ Parsed map[string]*string `json:"parsed"`
+ }
+ if err := json.Unmarshal(out, &got); err != nil {
+ t.Fatalf("decode %q: %v", out, err)
+ }
+ en, ok := fiesta.Strings("en")
+ if !ok {
+ t.Fatal("i18n/en.json is not shipped")
+ }
+ var table struct {
+ Words map[string]string `json:"date-words"`
+ }
+ if err := json.Unmarshal(en, &table); err != nil {
+ t.Fatal(err)
+ }
+ if len(table.Words) == 0 {
+ t.Fatal("i18n/en.json has no date-words to hold the fallback to")
+ }
+ if !maps.Equal(got.Words, table.Words) {
+ t.Errorf("the English fallback vocabulary is not i18n/en.json's date-words:\n script: %v\n table: %v", got.Words, table.Words)
+ }
+ want := map[string]string{"tomorrow": "2026-10-29", "in 2 weeks": "2026-11-11"}
+ for _, phrase := range phrases {
+ read := got.Parsed[phrase]
+ if read == nil {
+ t.Errorf("%q read as nothing with no table and no attribute; the README promises it parses", phrase)
+ continue
+ }
+ at, err := time.Parse(time.RFC3339Nano, *read)
+ if err != nil {
+ t.Errorf("%q read as %q, not an instant: %v", phrase, *read, err)
+ continue
+ }
+ if day, ok := want[phrase]; ok && at.Format(time.DateOnly) != day {
+ t.Errorf("%q read as %s, want %s", phrase, at.Format(time.DateOnly), day)
+ }
+ if phrase == "next fri 9am" && (at.Weekday() != time.Friday || at.Hour() != 9) {
+ t.Errorf("%q read as %s, want a Friday at 09:00", phrase, at.Format(time.RFC3339))
+ }
+ }
+}
diff --git a/check/node/vocab_node.mjs b/check/node/vocab_node.mjs
new file mode 100644
index 0000000..8dffa99
--- /dev/null
+++ b/check/node/vocab_node.mjs
@@ -0,0 +1,56 @@
+// vocab_node drives datetime.js's vocabulary resolution in plain Node
+// against a stub page: a document whose lang is en, no
+// self.fiestaStrings, and a field with no attributes, so the only
+// vocabulary left is the script's own English. It reads back one JSON
+// object: "words", the vocabulary the field resolved to, and "parsed",
+// each phrase on stdin to the ISO instant the parser read it as (null
+// for no reading), at a fixed now of 2026-10-28 10:30 local.
+//
+// echo '{"phrases":["tomorrow","next fri 9am","in 2 weeks"]}' |
+// node node/vocab_node.mjs
+//
+// check/datetime_test.go is the only caller. It holds the words to
+// i18n/en.json's date-words and wants every phrase read, which is what
+// the README promises of an English page that loads no table.
+import { readFileSync } from "node:fs";
+import vm from "node:vm";
+
+const src = readFileSync(new URL("../../js/datetime.js", import.meta.url), "utf8");
+
+const chunks = [];
+for await (const chunk of process.stdin) chunks.push(chunk);
+const { phrases = [] } = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
+
+// The least DOM the script touches on load: a loading document whose
+// root carries lang, and a self holding no registry at all.
+const root = { lang: "en", getAttribute: (k) => (k === "lang" ? "en" : null) };
+const document = {
+ readyState: "loading",
+ documentElement: root,
+ addEventListener() {},
+ querySelectorAll: () => [],
+};
+const self = {};
+const module = { exports: {} };
+vm.runInNewContext(src, { document, self, window: self, module, exports: module.exports, setTimeout, clearTimeout }, { filename: "datetime.js" });
+
+const { vocabulary, parse, tables } = module.exports;
+if (typeof vocabulary !== "function") {
+ process.stderr.write("datetime.js does not export vocabulary\n");
+ process.exit(1);
+}
+// A field with no attributes at all.
+const words = vocabulary({ getAttribute: () => null });
+const now = new Date(2026, 9, 28, 10, 30);
+const tbl = tables("en");
+const parsed = {};
+for (const phrase of phrases) {
+ let read = null;
+ try {
+ read = parse(phrase, words, tbl, now, {});
+ } catch (e) {
+ read = null;
+ }
+ parsed[phrase] = read && read.date ? read.date.toISOString() : null;
+}
+process.stdout.write(JSON.stringify({ words, parsed }));
diff --git a/check/scripts_test.go b/check/scripts_test.go
index 173d9e3..354085c 100644
--- a/check/scripts_test.go
+++ b/check/scripts_test.go
@@ -556,7 +556,14 @@ func TestSelectContract(t *testing.T) {
// list fixed or anchored out of a bar that scrolls sideways had
// opened 0px tall. Rastrillo holds the same code at 55KB; the
// string tables above are the 1,473 bytes between the two caps.
- if n := len(js); n > 57*1024 {
+ //
+ // Then to 59KB (measured 59,673 bytes, +1,886) by the native
+ // state: a disabled select's box is disabled with it and refuses
+ // every row, an option under a disabled <optgroup> is off limits,
+ // a disabled <fieldset> is read at each choice, and a form reset
+ // repaints the box a task later. 58KB (59,392) does not hold it;
+ // 59KB (60,416) does, with 743 to spare.
+ if n := len(js); n > 59*1024 {
t.Fatalf("select.js is %d bytes; it is split out of the shim precisely to stay readable — trim it", n)
}
if strings.Contains(js, "\t") {
diff --git a/check/select_browser_test.go b/check/select_browser_test.go
index 8a5124b..39ae10b 100644
--- a/check/select_browser_test.go
+++ b/check/select_browser_test.go
@@ -1964,3 +1964,249 @@ func TestSelectOptionClassStylesRowAndBox(t *testing.T) {
t.Errorf("a pick with no data-rst-class leaves the box wearing %q, want nothing", got.Plain)
}
}
+
+// A select the page cannot change is a box that cannot change it. The
+// box takes the select's disabled state and follows it; a row is
+// refused while the select is disabled, whichever way it was reached;
+// and a select inside a disabled <fieldset>, which sets no attribute,
+// is read as disabled at the moment of the choice. Each leg tries every
+// way of choosing and reads the select's value and what the form posts.
+func TestADisabledSelectCannotBeChangedThroughTheBox(t *testing.T) {
+ t.Parallel()
+ var got struct {
+ Before string `json:"before"`
+ BoxDisabled bool `json:"boxDisabled"`
+ ListOpen bool `json:"listOpen"`
+ ValueDisabled string `json:"valueDisabled"`
+ BoxEnabled bool `json:"boxEnabled"`
+ PostedAfter string `json:"postedAfter"`
+ ChangedOnce string `json:"changedOnce"`
+ InFieldset bool `json:"inFieldset"`
+ ValueInSet string `json:"valueInSet"`
+ ListOpenInSet bool `json:"listOpenInSet"`
+ PostedOutOfSet string `json:"postedOutOfSet"`
+ }
+ drive(t, `
+ const frame = () => `+afterFrame+`;
+ const out = {};
+ const form = document.querySelector('form');
+ const posted = () => new FormData(form).get('colour');
+ const select = document.querySelector('#colour');
+ const input = document.querySelector('#colour-combo');
+ const list = document.querySelector('#colour-listbox');
+ // Every way of choosing Green: focus and click (a disabled box
+ // refuses both natively), words and Enter, and a row.
+ const tryGreen = () => {
+ input.focus();
+ input.click();
+ input.value = 'green';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ const row = [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === 'Green');
+ row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ input.blur();
+ };
+ out.before = posted();
+ select.disabled = true;
+ await frame();
+ out.boxDisabled = input.disabled;
+ tryGreen();
+ await frame();
+ out.listOpen = !list.hidden;
+ out.valueDisabled = select.value;
+ select.disabled = false;
+ await frame();
+ out.boxEnabled = !input.disabled;
+ out.postedAfter = posted();
+ // Enabled again, the same moves choose Green: the legs above
+ // were refused by the state, not by a box that cannot choose.
+ tryGreen();
+ await frame();
+ out.changedOnce = posted();
+ // A disabled fieldset around the select sets no attribute.
+ const fs = document.createElement('fieldset');
+ const host = document.querySelector('#colour-host');
+ host.replaceWith(fs);
+ fs.append(host);
+ await frame();
+ fs.disabled = true;
+ await frame();
+ out.inFieldset = input.matches(':disabled');
+ const blue = [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === 'Blue');
+ input.value = 'blue';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ blue.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ blue.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ await frame();
+ out.listOpenInSet = !list.hidden;
+ out.valueInSet = select.value;
+ fs.disabled = false;
+ await frame();
+ out.postedOutOfSet = posted();
+ return out;
+ `, &got)
+ if got.Before != "red" {
+ t.Fatalf("the colour select starts at %q, want red", got.Before)
+ }
+ if !got.BoxDisabled {
+ t.Error("the select was disabled and its box was not")
+ }
+ if got.ListOpen || got.ValueDisabled != "red" || got.PostedAfter != "red" {
+ t.Errorf("disabled, the box let Green through: list open %v, value %q, posted once enabled %q; want closed, red, red", got.ListOpen, got.ValueDisabled, got.PostedAfter)
+ }
+ if !got.BoxEnabled {
+ t.Error("the select was enabled again and its box stayed disabled")
+ }
+ if got.ChangedOnce != "green" {
+ t.Errorf("enabled again, the same moves posted %q, want green: the box could not choose at all", got.ChangedOnce)
+ }
+ if !got.InFieldset {
+ t.Fatal("inside a disabled fieldset, the box does not read as :disabled; the fieldset leg is not set up")
+ }
+ if got.ListOpenInSet || got.ValueInSet != "green" || got.PostedOutOfSet != "green" {
+ t.Errorf("in a disabled fieldset, the box let Blue through: list open %v, value %q, posted once enabled %q; want closed, green, green", got.ListOpenInSet, got.ValueInSet, got.PostedOutOfSet)
+ }
+}
+
+// An option under a disabled <optgroup> is disabled with it: the native
+// select will not pick it, so its row is marked disabled and refused,
+// by words and Enter and by a click. A sibling option under an enabled
+// group still chooses, so the refusal is the group's, not the box's.
+func TestAnOptionUnderADisabledGroupCannotBeChosen(t *testing.T) {
+ t.Parallel()
+ var got struct {
+ Before string `json:"before"`
+ RowDisabled string `json:"rowDisabled"`
+ AfterBill string `json:"afterBill"`
+ AfterRet string `json:"afterRet"`
+ Posted string `json:"posted"`
+ }
+ drive(t, `
+ const frame = () => `+afterFrame+`;
+ const out = {};
+ const form = document.querySelector('form');
+ const select = document.querySelector('#dept');
+ out.before = select.value;
+ select.querySelector('optgroup[label="Support"]').disabled = true;
+ await frame();
+ await frame();
+ const input = document.querySelector('#dept-combo');
+ const list = document.querySelector('#dept-listbox');
+ const row = (text) => [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === text);
+ out.rowDisabled = row('Billing').getAttribute('aria-disabled') || '';
+ const choose = (words, text) => {
+ input.focus();
+ input.value = words;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ const li = row(text);
+ li.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ li.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
+ input.blur();
+ };
+ choose('bil', 'Billing');
+ await frame();
+ out.afterBill = select.value;
+ choose('ret', 'Retail');
+ await frame();
+ out.afterRet = select.value;
+ out.posted = new FormData(form).get('dept');
+ return out;
+ `, &got)
+ if got.Before != "acc" {
+ t.Fatalf("the department select starts at %q, want acc", got.Before)
+ }
+ if got.RowDisabled != "true" {
+ t.Errorf("Billing's row is aria-disabled=%q under a disabled group, want true", got.RowDisabled)
+ }
+ if got.AfterBill != "acc" {
+ t.Errorf("Billing, under a disabled group, was chosen: the select is %q, want acc", got.AfterBill)
+ }
+ if got.AfterRet != "ret" || got.Posted != "ret" {
+ t.Errorf("Retail, under an enabled group, was not chosen: the select is %q and the form posts %q, want ret", got.AfterRet, got.Posted)
+ }
+}
+
+// A form reset puts the select back to its default without a change
+// event, and the box, whose own default is empty, resets to blank: the
+// page showed nothing while the form posted the default. After an
+// uncancelled reset, through form.reset() or a reset button, the box
+// shows the select's value again and an open list closes; a reset a
+// listener cancelled leaves the pick in place.
+func TestAFormResetRepaintsTheBox(t *testing.T) {
+ t.Parallel()
+ var got struct {
+ Picked string `json:"picked"`
+ PostedPick string `json:"postedPick"`
+ ShownReset string `json:"shownReset"`
+ PostedReset string `json:"postedReset"`
+ ListReset bool `json:"listReset"`
+ ShownButton string `json:"shownButton"`
+ PostedButton string `json:"postedButton"`
+ ShownKept string `json:"shownKept"`
+ PostedKept string `json:"postedKept"`
+ }
+ drive(t, `
+ const frame = () => `+afterFrame+`;
+ const tick = () => new Promise((r) => setTimeout(r, 0));
+ const out = {};
+ const form = document.querySelector('form');
+ const posted = () => new FormData(form).get('colour');
+ const input = document.querySelector('#colour-combo');
+ const list = document.querySelector('#colour-listbox');
+ const pick = (words) => {
+ input.focus();
+ input.value = words;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
+ input.blur();
+ };
+ pick('green');
+ out.picked = input.value;
+ out.postedPick = posted();
+ // Open the list, then reset through the API.
+ input.focus();
+ input.click();
+ await frame();
+ form.reset();
+ await tick();
+ await frame();
+ out.listReset = !list.hidden;
+ out.shownReset = input.value;
+ out.postedReset = posted();
+ input.blur();
+ // Again through a reset button.
+ pick('blue');
+ const button = document.createElement('button');
+ button.type = 'reset';
+ form.append(button);
+ button.click();
+ await tick();
+ await frame();
+ out.shownButton = input.value;
+ out.postedButton = posted();
+ // A cancelled reset changes nothing.
+ pick('green');
+ form.addEventListener('reset', (e) => e.preventDefault(), { once: true });
+ form.reset();
+ await tick();
+ await frame();
+ out.shownKept = input.value;
+ out.postedKept = posted();
+ return out;
+ `, &got)
+ if got.Picked != "Green" || got.PostedPick != "green" {
+ t.Fatalf("picking Green shows %q and posts %q; the drive's setup did not take", got.Picked, got.PostedPick)
+ }
+ if got.ListReset || got.ShownReset != "Red" || got.PostedReset != "red" {
+ t.Errorf("after form.reset(): list open %v, box shows %q, form posts %q; want closed, Red, red", got.ListReset, got.ShownReset, got.PostedReset)
+ }
+ if got.ShownButton != "Red" || got.PostedButton != "red" {
+ t.Errorf("after a reset button: box shows %q, form posts %q; want Red, red", got.ShownButton, got.PostedButton)
+ }
+ if got.ShownKept != "Green" || got.PostedKept != "green" {
+ t.Errorf("after a cancelled reset: box shows %q, form posts %q; want Green, green", got.ShownKept, got.PostedKept)
+ }
+}
diff --git a/check/select_test.go b/check/select_test.go
index 0fb3217..d0565f2 100644
--- a/check/select_test.go
+++ b/check/select_test.go
@@ -265,7 +265,7 @@ func TestSelectUsesItsPromptRules(t *testing.T) {
t.Parallel()
src := string(shippedScript(t, "select.js"))
for _, want := range []string{
- "prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, o.disabled),",
+ "prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, offLimits(o)),",
"offered(o.el.value, o.marked, native.required, searching, o.disabled)",
// Up from nothing highlighted goes to the last row.
"setActive((active ? v[Math.max(v.indexOf(active) - 1, 0)] : v[v.length - 1]) || null);",
@@ -284,7 +284,7 @@ func TestSelectUsesItsPromptRules(t *testing.T) {
"if (open) {\n e.preventDefault();\n tookEnter = true;\n if (active) choose(active);",
// A select that stops being required re-derives its prompts and
// re-marks the selection.
- "if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, o.el.disabled);\n }\n markSelected();",
+ "if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, offLimits(o.el));\n }\n markSelected();",
} {
if !strings.Contains(src, want) {
t.Errorf("select.js no longer calls %q", want)
diff --git a/js/datetime.js b/js/datetime.js
index ac6697d..bac6e33 100644
--- a/js/datetime.js
+++ b/js/datetime.js
@@ -1126,14 +1126,7 @@
var locale = document.documentElement.getAttribute("lang") || undefined;
var tbl = tables(locale);
var fmt = formatter(locale, kind);
- var vocab = {};
- try {
- // The attribute holds JSON text; the table holds the object.
- var words = fiestaWord(native, "date-words", {});
- vocab = (typeof words === "string" ? JSON.parse(words) : words) || {};
- } catch (e) {
- vocab = {};
- }
+ var vocab = vocabulary(native);
var id = native.id || "rst-dtp-" + ++seq;
var listId = id + "-listbox";
@@ -1450,6 +1443,7 @@
}
function openList() {
+ if (locked()) return;
if (cal) cal.close();
mode = "list";
list.hidden = false;
@@ -1461,6 +1455,7 @@
}
function openCal() {
+ if (locked()) return;
list.hidden = true;
active = -1;
input.removeAttribute("aria-activedescendant");
@@ -1481,6 +1476,7 @@
// list element and the same keys as the suggestions — one popup,
// one Escape, one set of arrows.
function openSlots() {
+ if (locked()) return;
var base = current() || new Date();
var day = midnight(base);
var want = base.getHours() * 60 + base.getMinutes();
@@ -1510,7 +1506,27 @@
}
}
+ // The native field's own state. A disabled field is disabled
+ // throughout, box and button. A readonly one shows its value and
+ // refuses every way of changing it: typing, the suggestions, the
+ // quick picks, the grid and the clock. Followed as the attributes
+ // change, and read again at each commit, since a disabled
+ // <fieldset> sets neither attribute; the box needs no mirror for
+ // that, sitting inside the same fieldset as the field.
+ function locked() { return native.readOnly || native.matches(":disabled"); }
+ function follow() {
+ input.disabled = native.disabled;
+ input.readOnly = native.readOnly;
+ if (pick) pick.disabled = native.disabled || native.readOnly;
+ if (locked()) closeAll();
+ }
+ new MutationObserver(follow).observe(native, { attributes: true, attributeFilter: ["disabled", "readonly"] });
+ follow();
+
function commit(date, hasTime) {
+ // A field that cannot be changed is not changed, whichever
+ // way the value arrived: the box goes back to what it holds.
+ if (locked()) { paint(); closeAll(); return; }
// Ours, so the change listener below leaves the repaint alone:
// this path has already painted.
mine = true;
@@ -1523,6 +1539,7 @@
}
function commitText() {
+ if (locked()) { paint(); return; }
var text = input.value.trim();
if (!text) {
// Cleared on purpose: an emptied field empties the carrier,
@@ -1665,6 +1682,23 @@
paint();
});
+ // A form reset dispatches no change: the carrier goes back to
+ // its default, and the box, whose own default is empty, goes
+ // blank, so the page would show nothing while the form posted a
+ // date. The controls take their reset values only after the
+ // event has been dispatched, so the repaint waits a task, and a
+ // reset that a listener cancelled leaves everything as it was.
+ // On the document, since the field's form is read when the
+ // reset happens, not when the field was enhanced.
+ document.addEventListener("reset", function (e) {
+ if (e.target !== native.form) return;
+ setTimeout(function () {
+ if (e.defaultPrevented) return;
+ closeAll();
+ paint();
+ }, 0);
+ });
+
paint();
return {
@@ -1745,6 +1779,30 @@
return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback;
}
+ // The English vocabulary, i18n/en.json's date-words: the fallback
+ // when neither the attribute nor a loaded table spells one, as the
+ // labels fall back to their English. check/datetime_test.go holds
+ // it to the shipped table, so a word improved there is improved
+ // here.
+ var ENGLISH_WORDS = {
+ ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in",
+ last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m",
+ month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.",
+ today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday"
+ };
+
+ // The parser's vocabulary for one field: the attribute (JSON text),
+ // else the page's table (the object), else English. An attribute
+ // that does not parse is no vocabulary, and English again.
+ function vocabulary(native) {
+ try {
+ var words = fiestaWord(native, "date-words", ENGLISH_WORDS);
+ return (typeof words === "string" ? JSON.parse(words) : words) || ENGLISH_WORDS;
+ } catch (e) {
+ return ENGLISH_WORDS;
+ }
+ }
+
// The pure half, for check/node/datetime_node.mjs, and the lookup
// for node/strings_node.mjs. A browser has no `module` and never
// takes this branch.
@@ -1754,6 +1812,7 @@
parseAll: parseAll,
tables: tables,
displayOptions: displayOptions,
+ vocabulary: vocabulary,
fiestaPickLang: fiestaPickLang,
fiestaWord: fiestaWord
};
diff --git a/js/select.js b/js/select.js
index caf37f4..ffe44b8 100644
--- a/js/select.js
+++ b/js/select.js
@@ -312,6 +312,12 @@
// knows the list it belongs in, and a search that lifts it out puts
// it back there.
const groups = [];
+ // An option under a disabled <optgroup> is disabled with it: the
+ // native select will not pick it, so no row may either.
+ const offLimits = (o) => o.disabled || !!(o.parentElement && o.parentElement.tagName === "OPTGROUP" && o.parentElement.disabled);
+ // The select's own state, read when it matters: a disabled
+ // <fieldset> disables the select without setting its attribute.
+ const locked = () => native.matches(":disabled");
const opts = Array.from(native.options).map((o, i) => {
const d = o.dataset;
const g = o.parentElement.tagName === "OPTGROUP" ? o.parentElement : null;
@@ -328,10 +334,10 @@
marked: d.rstPrompt !== undefined,
// Whether it is a prompt can change: a sibling script may stop the
// select being required, so the observer below re-derives it.
- prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, o.disabled),
+ prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, offLimits(o)),
first: d.rstFirst !== undefined,
sepBefore: !!(o.previousElementSibling && o.previousElementSibling.tagName === "HR"),
- disabled: o.disabled,
+ disabled: offLimits(o),
blank: o.value === "",
index: i,
group: g,
@@ -362,6 +368,10 @@
input.id = id + "-combo";
input.autocomplete = "off";
input.spellcheck = false;
+ // A disabled select's box is disabled with it (the flips observer
+ // below keeps the two together). A disabled <fieldset> needs no
+ // mirror: the box sits beside the select, inside the same fieldset.
+ input.disabled = native.disabled;
// A compact box is too narrow for a search sentence, so it says what
// it is for in the one word it is labelled with.
input.placeholder = compact && native.getAttribute("aria-label")
@@ -783,7 +793,7 @@
};
const openList = (showAll) => {
- if (open) return;
+ if (open || locked()) return;
open = true;
list.hidden = false;
input.setAttribute("aria-expanded", "true");
@@ -838,7 +848,9 @@
};
const choose = (o) => {
- if (!o || o.disabled) return;
+ // A disabled select, or one in a disabled fieldset, changes for
+ // nobody: the row is refused here, whichever way it was reached.
+ if (!o || o.disabled || locked()) return;
// An extra row does something instead of BEING something: there is
// nothing to select, and the page decides what happens to the text.
if (o.run) {
@@ -1032,18 +1044,35 @@
if (open) filter(query());
};
- // A write from outside (reset, autofill, a sibling script) dispatches
+ // A write from outside (autofill, a sibling script) dispatches
// "change", and the display follows. choose()'s own lands as a no-op.
// Listeners this box puts on the select, which outlives it: removed in
// one go when the box steps aside, or every rebuild would leave one
// more set answering the select's events for a box no longer there.
const mine = new AbortController();
- native.addEventListener("change", () => {
+ const follow = () => {
showText();
showPick();
markSelected();
input.setCustomValidity("");
settleValidity();
+ };
+ native.addEventListener("change", follow, { signal: mine.signal });
+ // A form reset dispatches no change: the select goes back to its
+ // default, and the box, whose own default is empty, goes blank, so
+ // the page would show nothing while the form posted the default.
+ // The controls take their reset values only after the event has
+ // been dispatched, so the repaint waits a task, and a reset that a
+ // listener cancelled leaves everything as it was. On the document,
+ // since the select's form is read when the reset happens, not when
+ // the box was built.
+ document.addEventListener("reset", (e) => {
+ if (e.target !== native.form) return;
+ setTimeout(() => {
+ if (e.defaultPrevented || gone) return;
+ closeList(false);
+ follow();
+ }, 0);
}, { signal: mine.signal });
// A REQUIRED select still validates — it is still what the form posts —
@@ -1071,10 +1100,14 @@
// without touching it otherwise. That changes which blanks are prompts,
// and so what the box shows and which row is selected.
const flips = new MutationObserver(() => {
+ // The box is disabled with the select, and a list open at that
+ // moment closes: nothing in it can be chosen now.
+ input.disabled = native.disabled;
+ if (native.disabled) closeList(false);
settleRequired();
settleValidity();
for (const o of opts) {
- if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, o.el.disabled);
+ if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, offLimits(o.el));
}
markSelected();
// An open list re-reads its rows: a blank that just became a prompt
diff --git a/releases/1.0.0/SHA256SUMS b/releases/1.0.0/SHA256SUMS
index 78e4a01..35a1ca5 100644
--- a/releases/1.0.0/SHA256SUMS
+++ b/releases/1.0.0/SHA256SUMS
@@ -3,7 +3,7 @@ b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57 NOTICE
40bb2c04c08f57539ee293e0e26d421c108425dfdd0da0401ee989157fe57bab anchor.js
f5a0406b7b4aca24539dd8ababe0fdc45111f346cce7667ed56d4ce4858e700e busy.js
6db97e886b0cf78806e88ffd8b4893e185387acb6d4d33924b20331a5ab6ced3 calendar.js
-46b8db55554fe36297835e08473191d4f75f80bad0e6c49a5e0e22702493b371 datetime.js
+0335befa2c954b92bb6ab99b5c994995a4ef49b4c67f171e48db3ace0869778d datetime.js
369b0e6f349a8e40c80c0d510f0541e249f78aba5ffaf0362d9bc685dca3cfac day.css
256285cd4933c9ec094c4a02c68c97cea6381b30add06885ff2d806768cf5aed fiesta-plain.css
eaf51f25dd3ad66d8f8e8511c005e19a70cc5306be6e125f046d2a5294256532 fiesta-signal.css
@@ -48,7 +48,7 @@ e8715564dabc19ef828859514d9c11b22ad8fe18373623941fff70369c0e1c42 icons/kebab.sv
d9f6b21fe977c603adf81e7723c22686d36cb6161e5b4538d6c8e9248f3dedb7 icons/x.svg
ae8944e9d07ac2a0916bfcaf2c0b79c04a213c84b190ba80217575daa82b01a4 light.js
3f06a1419ab1c2b041f6c9cf6ca3c14be6f84fd907d2d788f44e9324f4b43b7b plain.css
-600d638dd20748c4fc285773d391d3210218aac93291688f05138128b3b6376d select.js
+a7cabd8adddc896a59c279a33b65f1275b49261b6bc887cefe11e758aed7bd0b select.js
b34b939d14cbd39e64b4ed8f43b46d74b872b3de8a080a8c00715a9946050886 shell.css
660437eb72548f22a421e77c40d3b16be1a287dd55e52adb48c3a8ff93042a8b shell.js
96cd29a0c52a09a71ddd12e64d762a3980f5c1119323f7a0be644659277cca99 signal.css
diff --git a/releases/1.0.0/datetime.js b/releases/1.0.0/datetime.js
index 03fa84b..59a63af 100644
--- a/releases/1.0.0/datetime.js
+++ b/releases/1.0.0/datetime.js
@@ -722,13 +722,7 @@
var locale = document.documentElement.getAttribute("lang") || undefined;
var tbl = tables(locale);
var fmt = formatter(locale, kind);
- var vocab = {};
- try {
- var words = fiestaWord(native, "date-words", {});
- vocab = (typeof words === "string" ? JSON.parse(words) : words) || {};
- } catch (e) {
- vocab = {};
- }
+ var vocab = vocabulary(native);
var id = native.id || "rst-dtp-" + ++seq;
var listId = id + "-listbox";
@@ -984,6 +978,7 @@
}
function openList() {
+ if (locked()) return;
if (cal) cal.close();
mode = "list";
list.hidden = false;
@@ -995,6 +990,7 @@
}
function openCal() {
+ if (locked()) return;
list.hidden = true;
active = -1;
input.removeAttribute("aria-activedescendant");
@@ -1007,6 +1003,7 @@
}
function openSlots() {
+ if (locked()) return;
var base = current() || new Date();
var day = midnight(base);
var want = base.getHours() * 60 + base.getMinutes();
@@ -1033,7 +1030,18 @@
}
}
+ function locked() { return native.readOnly || native.matches(":disabled"); }
+ function follow() {
+ input.disabled = native.disabled;
+ input.readOnly = native.readOnly;
+ if (pick) pick.disabled = native.disabled || native.readOnly;
+ if (locked()) closeAll();
+ }
+ new MutationObserver(follow).observe(native, { attributes: true, attributeFilter: ["disabled", "readonly"] });
+ follow();
+
function commit(date, hasTime) {
+ if (locked()) { paint(); closeAll(); return; }
mine = true;
native.value = toWire(kind, date);
native.dispatchEvent(new Event("change", { bubbles: true }));
@@ -1044,6 +1052,7 @@
}
function commitText() {
+ if (locked()) { paint(); return; }
var text = input.value.trim();
if (!text) {
input.value = "";
@@ -1135,6 +1144,15 @@
paint();
});
+ document.addEventListener("reset", function (e) {
+ if (e.target !== native.form) return;
+ setTimeout(function () {
+ if (e.defaultPrevented) return;
+ closeAll();
+ paint();
+ }, 0);
+ });
+
paint();
return {
@@ -1201,12 +1219,29 @@
return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback;
}
+ var ENGLISH_WORDS = {
+ ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in",
+ last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m",
+ month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.",
+ today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday"
+ };
+
+ function vocabulary(native) {
+ try {
+ var words = fiestaWord(native, "date-words", ENGLISH_WORDS);
+ return (typeof words === "string" ? JSON.parse(words) : words) || ENGLISH_WORDS;
+ } catch (e) {
+ return ENGLISH_WORDS;
+ }
+ }
+
if (typeof module !== "undefined" && module && module.exports) {
module.exports = {
parse: parse,
parseAll: parseAll,
tables: tables,
displayOptions: displayOptions,
+ vocabulary: vocabulary,
fiestaPickLang: fiestaPickLang,
fiestaWord: fiestaWord
};
diff --git a/releases/1.0.0/select.js b/releases/1.0.0/select.js
index 36a81fa..62ae880 100644
--- a/releases/1.0.0/select.js
+++ b/releases/1.0.0/select.js
@@ -150,6 +150,8 @@
const oneFmt = say("result-one", "1 result");
const groups = [];
+ const offLimits = (o) => o.disabled || !!(o.parentElement && o.parentElement.tagName === "OPTGROUP" && o.parentElement.disabled);
+ const locked = () => native.matches(":disabled");
const opts = Array.from(native.options).map((o, i) => {
const d = o.dataset;
const g = o.parentElement.tagName === "OPTGROUP" ? o.parentElement : null;
@@ -163,10 +165,10 @@
classes: (d.rstClass || "").split(/\s+/).filter((c) => c && c !== "is-active"),
terms: (d.rstTerms || "").split(/\s+/).filter(Boolean),
marked: d.rstPrompt !== undefined,
- prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, o.disabled),
+ prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, offLimits(o)),
first: d.rstFirst !== undefined,
sepBefore: !!(o.previousElementSibling && o.previousElementSibling.tagName === "HR"),
- disabled: o.disabled,
+ disabled: offLimits(o),
blank: o.value === "",
index: i,
group: g,
@@ -190,6 +192,7 @@
input.id = id + "-combo";
input.autocomplete = "off";
input.spellcheck = false;
+ input.disabled = native.disabled;
input.placeholder = compact && native.getAttribute("aria-label")
? native.getAttribute("aria-label")
: say("filter", "Type to filter");
@@ -505,7 +508,7 @@
};
const openList = (showAll) => {
- if (open) return;
+ if (open || locked()) return;
open = true;
list.hidden = false;
input.setAttribute("aria-expanded", "true");
@@ -549,7 +552,7 @@
};
const choose = (o) => {
- if (!o || o.disabled) return;
+ if (!o || o.disabled || locked()) return;
if (o.run) {
o.run();
return;
@@ -701,12 +704,21 @@
};
const mine = new AbortController();
- native.addEventListener("change", () => {
+ const follow = () => {
showText();
showPick();
markSelected();
input.setCustomValidity("");
settleValidity();
+ };
+ native.addEventListener("change", follow, { signal: mine.signal });
+ document.addEventListener("reset", (e) => {
+ if (e.target !== native.form) return;
+ setTimeout(() => {
+ if (e.defaultPrevented || gone) return;
+ closeList(false);
+ follow();
+ }, 0);
}, { signal: mine.signal });
const settleValidity = () => {
@@ -721,10 +733,12 @@
};
settleRequired();
const flips = new MutationObserver(() => {
+ input.disabled = native.disabled;
+ if (native.disabled) closeList(false);
settleRequired();
settleValidity();
for (const o of opts) {
- if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, o.el.disabled);
+ if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, offLimits(o.el));
}
markSelected();
if (open) {