@vanilla-bean/components 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/Component/observeElementConnection.js +3 -3
  2. package/Component/observeElementConnection.test.js +15 -0
  3. package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
  4. package/components/BottomSheet/BottomSheet.js +8 -2
  5. package/components/BottomSheet/BottomSheet.lld.md +22 -10
  6. package/components/Button/Button.lld.md +11 -3
  7. package/components/Calendar/Calendar.js +27 -10
  8. package/components/Calendar/Calendar.lld.md +42 -6
  9. package/components/Calendar/Toolbar.js +16 -0
  10. package/components/Code/Code.lld.md +3 -3
  11. package/components/ColorPicker/ColorPicker.lld.md +27 -4
  12. package/components/Dialog/Dialog.lld.md +14 -0
  13. package/components/Form/Form.lld.md +33 -3
  14. package/components/Icon/Icon.js +24 -3
  15. package/components/Icon/Icon.lld.md +21 -3
  16. package/components/Input/Input.lld.md +30 -6
  17. package/components/Keyboard/Keyboard.js +1 -1
  18. package/components/Keyboard/Keyboard.lld.md +25 -4
  19. package/components/Label/Label.js +10 -2
  20. package/components/Label/Label.lld.md +31 -1
  21. package/components/Link/Link.js +18 -1
  22. package/components/Link/Link.lld.md +8 -1
  23. package/components/List/List.lld.md +9 -2
  24. package/components/Menu/Menu.js +10 -1
  25. package/components/Menu/Menu.lld.md +23 -2
  26. package/components/Notify/Notify.lld.md +16 -4
  27. package/components/Page/Page.lld.md +15 -4
  28. package/components/Popover/Popover.lld.md +5 -1
  29. package/components/RadioButton/RadioButton.js +6 -1
  30. package/components/RadioButton/RadioButton.lld.md +14 -1
  31. package/components/Router/Router.js +6 -14
  32. package/components/Router/Router.lld.md +16 -11
  33. package/components/Select/Select.js +22 -3
  34. package/components/Select/Select.lld.md +26 -2
  35. package/components/Table/Table.js +19 -4
  36. package/components/Table/Table.lld.md +19 -5
  37. package/components/TagList/Tag.js +14 -0
  38. package/components/TagList/TagList.js +0 -18
  39. package/components/TagList/TagList.lld.md +12 -7
  40. package/components/Tooltip/Tooltip.lld.md +12 -4
  41. package/components/TooltipWrapper/TooltipWrapper.lld.md +14 -0
  42. package/components/Whiteboard/Whiteboard.js +10 -2
  43. package/components/Whiteboard/Whiteboard.lld.md +25 -11
  44. package/devTools/lldRunner.js +41 -0
  45. package/eslint.config.cjs +4 -0
  46. package/index.d.ts +8 -4
  47. package/package.json +5 -2
  48. package/spellcheck.config.cjs +1 -0
  49. package/theme/button.js +9 -0
  50. package/theme/page.js +62 -0
  51. package/Component/Component.scenarios.js +0 -88
@@ -11,12 +11,12 @@ function ensureObserver() {
11
11
  for (const mutation of mutations) {
12
12
  for (const node of mutation.removedNodes) {
13
13
  for (const [target, callbacks] of registry) {
14
- if (node === target || node.contains?.(target)) callbacks.onDisconnected(mutation);
14
+ if ((node === target || node.contains?.(target)) && !target.isConnected) callbacks.onDisconnected(mutation);
15
15
  }
16
16
  }
17
17
  for (const node of mutation.addedNodes) {
18
18
  for (const [target, callbacks] of registry) {
19
- if (node === target || node.contains?.(target)) callbacks.onConnected(mutation);
19
+ if ((node === target || node.contains?.(target)) && target.isConnected) callbacks.onConnected(mutation);
20
20
  }
21
21
  }
22
22
  }
@@ -28,7 +28,7 @@ function ensureObserver() {
28
28
  /**
29
29
  * Observe DOM connection/disconnection of an element via a shared MutationObserver.
30
30
  * All registrations share one observer; the observer is torn down when no targets remain.
31
- * Fires correctly when the target itself or any ancestor is moved.
31
+ * Fires when the target itself or any ancestor is added or removed; a move within the document is not a disconnect.
32
32
  * @param {object} config - Observer registration options
33
33
  * @param {Node} config.target - Target element to watch for add/remove
34
34
  * @param {Function} config.onConnected - Called when target is added to the document
@@ -66,6 +66,21 @@ describe('observeElementConnection', () => {
66
66
  handle.disconnect();
67
67
  });
68
68
 
69
+ test('does not fire onDisconnected when the target moves within the document', async () => {
70
+ const target = document.createElement('div');
71
+ document.body.appendChild(target);
72
+ await tick();
73
+
74
+ const onDisconnected = mock();
75
+ const handle = observeElementConnection({ target, onConnected: mock(), onDisconnected });
76
+
77
+ document.body.appendChild(target);
78
+ await tick();
79
+
80
+ expect(onDisconnected).not.toHaveBeenCalled();
81
+ handle.disconnect();
82
+ });
83
+
69
84
  test('does not fire after disconnect', async () => {
70
85
  const target = document.createElement('div');
71
86
  const onConnected = mock();
@@ -104,9 +104,15 @@ class BottomSheet extends styled(
104
104
  }
105
105
 
106
106
  /**
107
- * Slides the sheet out of view and calls onClose if set.
107
+ * Slides the sheet out of view. Calls `onClose` only if the sheet was actually open — hiding an
108
+ * already-closed sheet dismisses nothing, so there is nothing to announce.
108
109
  */
109
110
  hide() {
111
+ // `onClose` reports a dismissal. Firing it unconditionally meant a sheet that had never been
112
+ // shown reported being closed, and `hide()` called twice reported it twice — so a caller
113
+ // hiding defensively, or wiring hide() to more than one dismissal path, got spurious closes.
114
+ const wasOpen = this.elem.classList.contains('open');
115
+
110
116
  this.removeClass('open');
111
117
  if (this._hideOnNavigate) {
112
118
  window.removeEventListener('hashchange', this._hideOnNavigate);
@@ -114,7 +120,7 @@ class BottomSheet extends styled(
114
120
  this._hideOnNavigate = null;
115
121
  this.replaceDestroyCleanup('hideOnNavigate', () => {});
116
122
  }
117
- this.options.onClose?.();
123
+ if (wasOpen) this.options.onClose?.();
118
124
  }
119
125
 
120
126
  _initDragToClose() {
@@ -2,24 +2,36 @@
2
2
 
3
3
  > ./BottomSheet.js
4
4
 
5
- Bottom sheet overlay that slides up from the bottom. Mounts to `document.body` by default. Drag down to dismiss; `hide()` closes it from code.
5
+ Mobile-friendly overlay that slides up from the bottom of the screen. The design decision: dismissal has three distinct triggers -- explicit `hide()`, a drag gesture past a threshold, and navigating away -- but all three converge on the same dismissal path, so cleanup and `onClose` only need to be wired once no matter how the sheet closes.
6
6
 
7
7
  ## show() / hide() control visibility
8
8
 
9
9
  - calling `show()` slides the sheet into view; calling `hide()` slides it out
10
- - `onClose` fires when the sheet is dismissed (either by drag or by `hide()`)
11
- - does calling hide() invoke the onClose callback?
12
- - does the sheet animate out before calling onClose?
10
+ - `onClose` fires when the sheet is dismissed (either by drag or by `hide()`); hiding a sheet that is already closed dismisses nothing and announces nothing
11
+ - does calling show() then hide() invoke the onClose callback?
12
+ - does calling hide() on an already-closed sheet leave onClose uninvoked?
13
+ - new Array() captures atCallback then new Subject({ appendTo: document.body, onClose: () => atCallback.push(s.elem.classList.contains("open")) }) captures s then s.show() then s.hide() -> atCallback.length === 1 && atCallback[0] === false
13
14
 
14
15
  ## Drag below the threshold dismisses the sheet
15
16
 
17
+ **browser:** true
18
+
19
+ The threshold exists so that small accidental drags do not dismiss the sheet.
20
+
16
21
  - a drag handle at the top of the sheet captures pointer events; dragging past a distance threshold triggers `hide()`
17
- - the sheet should not dismiss on small accidental drags
18
- - does dragging the handle downward past the threshold dismiss the sheet?
19
- - does a short drag that doesn't reach the threshold leave the sheet visible?
22
+ - new Subject({ appendTo: document.body }) captures s then s.show() then s.elem.classList.contains("open") captures shown then s.elem.querySelector("*") captures zone then zone.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 300 })) then window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY: 700 })) then window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientY: 700 })) -> shown === true && s.elem.classList.contains("open") === false
23
+ - new Subject({ appendTo: document.body }) captures s then s.show() then s.elem.querySelector("*") captures zone then zone.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 300 })) then window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY: 304 })) then window.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientY: 304 })) -> s.elem.classList.contains("open") === true
20
24
 
21
25
  ## Navigating away (hashchange) closes the sheet
22
26
 
23
- - the sheet registers a one-time `hashchange` listener on `show()` and removes it on `hide()`
24
- - the sheet doesn't outlive navigation
25
- - does navigating to a new hash while the sheet is open dismiss it?
27
+ The sheet does not outlive the navigation that moved away from it.
28
+
29
+ - a sheet that is showing is dismissed when the location changes; a sheet that is already hidden is unaffected by navigation
30
+ - new Subject({ appendTo: document.body }) captures s then s.show() then s.elem.classList.contains("open") captures shown then location.hash = "#/elsewhere" then window.dispatchEvent(new HashChangeEvent("hashchange", { oldURL: "", newURL: String(location.href) })) -> shown === true && s.elem.classList.contains("open") === false
31
+
32
+ ## A sheet attaches to the page and offers its content region
33
+
34
+ An overlay that slides over everything belongs to the page, not to whatever component happened to construct it, so `document.body` is the default parent rather than a required option. Content goes in the sheet's own region rather than against the frame, which is what keeps the drag handle and the content from sharing a parent.
35
+
36
+ - a sheet with no explicit parent attaches to the document body and exposes the region its content belongs in
37
+ - new Subject({}) captures s -> s.elem.parentElement === document.body && !!s.body && s.body.elem.tagName === "DIV"
@@ -13,9 +13,17 @@ Activatable element that unifies pointer and keyboard interaction under one hand
13
13
  ## Caller-provided onKeyUp is preserved alongside activation logic
14
14
 
15
15
  - a caller-provided `onKeyUp` runs alongside the built-in keyboard activation; registering both handlers does not suppress either
16
- - does providing a custom onKeyUp still trigger onPointerPress on Space/Enter?
16
+ - new Array() captures calls then new Subject({ onPointerPress: () => calls.push("press"), onKeyUp: () => calls.push("custom") }) captures b then document.body.append(b.elem) then b.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", code: "Enter", bubbles: true })) -> calls.includes("press") && calls.includes("custom")
17
17
 
18
18
  ## Tooltip is automatic - no setup beyond the tooltip option
19
19
 
20
- - Button extends TooltipWrapper; a `tooltip` option produces tooltip behavior with no additional wiring
21
- - does a Button with a tooltip option show a tooltip on hover?
20
+ - a `tooltip` option produces tooltip behaviour with no additional wiring at the call site
21
+ - new Subject({ tooltip: "Save changes", appendTo: document.body }) captures b then b.elem.querySelector("[popover]") captures tip -> !!tip && tip.textContent === "Save changes"
22
+ - new Subject({ tooltip: "Save changes", appendTo: document.body }) captures b then b.elem.querySelector("[popover]") captures tip then tip.style.display captures before then b.elem.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, clientX: 10, clientY: 10 })) then await new Promise(r => setTimeout(r, 760)) -> before === "" && tip.style.display === "block"
23
+
24
+ ## Only Space and Enter activate; every other key passes through
25
+
26
+ Keyboard activation is deliberately two keys, not "any key". A button that fired on arrows or Tab would steal navigation from the page around it, which is the failure that makes keyboard users avoid custom controls.
27
+
28
+ - Space and Enter invoke `onPointerPress`; other keys leave it uninvoked
29
+ - new Array() captures fired then new Subject({ onPointerPress: e => fired.push(e.code), appendTo: document.body }) captures b then ["Enter", "Space", "KeyA", "Tab", "Escape"].forEach(code => b.elem.dispatchEvent(new KeyboardEvent("keyup", { key: code, code, bubbles: true }))) -> fired.join() === "Enter,Space"
@@ -44,7 +44,14 @@ const StyledComponent = styled(
44
44
  & div.event {
45
45
  position: absolute;
46
46
  pointer-events: all;
47
- width: 100%;
47
+ /* Offsets, not a width. Overlap resolution assigns left/right percentages so
48
+ simultaneous events share the row, and an explicit width over-constrained the
49
+ box against them — 'right' was dropped, so every event rendered full-width and
50
+ sat on top of its neighbours. Defaulting both offsets to 0 keeps a lone event
51
+ spanning the container, which is what the width was for, while letting the
52
+ assigned percentages actually narrow the box. */
53
+ left: 0;
54
+ right: 0;
48
55
  text-indent: 6px;
49
56
  }
50
57
  }
@@ -246,6 +253,7 @@ export const MONTHS = [
246
253
  * @param {number} [options.year] - Initial year to display, defaults to current year
247
254
  * @param {number} [options.month] - Initial month to display (0-11), defaults to current month
248
255
  * @param {number} [options.day] - Initial day to display, defaults to current day
256
+ * @param {number} [options.weekday] - Day-of-week index (0-6, Sunday first) as returned by Date.getDay(); set from the selected date and read by the week view
249
257
  * @param {Array<object>} [options.events=[]] - Calendar events array, automatically converted to CalendarEvent instances
250
258
  * @param {Array<string>} [options.views] - Available view modes for toolbar
251
259
  * @param {boolean} [options.display24h] - Whether to display time in 24-hour format in day view
@@ -377,6 +385,11 @@ class Calendar extends StyledComponent {
377
385
  event.ratio = 100;
378
386
  elem.style.left = '0';
379
387
 
388
+ // Which gap slot the event starts in (fractional - a start time that falls mid-slot
389
+ // spills into the next one below), and how many slots its duration spans.
390
+ event.gapCell = (event.hour * 60 + event.minute) / minGap;
391
+ event.gapCount = Math.max(1, Math.ceil((event.duration || minGap) / minGap));
392
+
380
393
  let totalGaps = event.gapCount;
381
394
 
382
395
  if (Math.floor(event.gapCell) !== event.gapCell) ++totalGaps;
@@ -557,7 +570,19 @@ class Calendar extends StyledComponent {
557
570
  }
558
571
 
559
572
  adjustDateToView() {
560
- if (this.options.view !== 'week' || this.options.weekday >= this.options.day || this.options.day > 8) return;
573
+ // Week view anchors on the month the week *starts* in, so a week spanning a month boundary is
574
+ // rendered from the earlier month. The week starts in the previous month exactly when its
575
+ // Sunday falls before the 1st -- `day - weekday < 1`. The guard used to ask whether
576
+ // `weekday < day`, which holds for most of any month's first week, so an ordinary date like
577
+ // Tuesday the 4th was pulled back a month and the calendar opened on the wrong week entirely.
578
+ // Derived, not read from `options.weekday`: that option is only populated by `setDate()`, so a
579
+ // caller who constructs with an explicit year/month/day leaves it undefined and any arithmetic
580
+ // on it yields NaN -- which compares false against everything and silently drops through.
581
+ if (this.options.view !== 'week') return;
582
+
583
+ const weekday = new Date(this.options.year, this.options.month, this.options.day).getDay();
584
+
585
+ if (this.options.day - weekday >= 1) return;
561
586
 
562
587
  let { year, month } = this.options;
563
588
  --month;
@@ -790,11 +815,3 @@ class Calendar extends StyledComponent {
790
815
  }
791
816
 
792
817
  export default Calendar;
793
-
794
- // Zero-arg scenarios for LLD verification
795
- export const navigateBackFromJanuary = () => {
796
- const c = new Calendar({ view: 'month', year: 2024, month: 0, autoRender: false });
797
- c.render();
798
- c.previous();
799
- return { month: c.options.month, year: c.options.year };
800
- };
@@ -6,17 +6,53 @@ Multi-view calendar that renders the same event data differently depending on sc
6
6
 
7
7
  ## Switching view re-renders layout without carrying forward view-specific state
8
8
 
9
- - day, week, and month views have structurally different DOM; changing `view` produces a fresh render so residual state from the previous layout cannot persist
10
- - does switching from month to day view reflect the events in the new layout?
9
+ - changing `view` produces a fresh render, so no residual state from the previous layout persists into the new one
10
+ - new Subject({ view: "month", appendTo: document.body }) captures c then c.elem.querySelectorAll("td").length captures monthCells then c.options.view = "week" then await new Promise(r => setTimeout(r, 20)) -> monthCells === 49 && c.elem.querySelectorAll("td").length === 0 && c.elem.classList.contains("week") && !c.elem.classList.contains("month")
11
+ - new Subject({ view: "month", events: [{ at: new Date(), label: "Standup" }], appendTo: document.body }) captures c then (c.elem.textContent.match(/Standup/g) ?? []).length captures inMonth then c.options.view = "day" then await new Promise(r => setTimeout(r, 20)) -> inMonth === 1 && (c.elem.textContent.match(/Standup/g) ?? []).length === 1
12
+
13
+ ## Times read as clock times, and the current day is findable
14
+
15
+ A 12-hour clock has no hour zero, so midnight is `12:00 AM`; `display24h` opts into the other convention. Whichever is in use, the cell for today is marked so the view has an anchor the reader can find without reading dates.
16
+
17
+ - in 12-hour mode midnight reads as 12, not 0; `display24h` switches to the 24-hour convention
18
+ - new Subject({ view: "day", year: 2026, month: 0, day: 15, appendTo: document.body }) captures twelve then Array.from(twelve.elem.querySelectorAll("*")).map(e => e.childNodes.length === 1 ? e.textContent : "").filter(t => /^\d{1,2}:\d{2}\s?(AM|PM)$/.test(t)) captures labels then new Subject({ view: "day", display24h: true, year: 2026, month: 0, day: 15, appendTo: document.body }) captures full then Array.from(full.elem.querySelectorAll("*")).map(e => e.childNodes.length === 1 ? e.textContent : "").filter(t => /^\d{1,2}:\d{2}/.test(t)) captures labels24 -> labels[0] === "12:00 AM" && !labels.some(l => l.startsWith("0:")) && labels24[0].startsWith("0:00")
19
+ - the cell for the current date carries a marker the other cells do not
20
+ - new Subject({ view: "week", appendTo: document.body }) captures c -> c.elem.querySelectorAll(".today").length === 1
21
+ - view and date methods return the calendar so calls can be chained
22
+ - new Subject({ appendTo: document.body }) captures c then c.setView("week") captures returned -> returned === c
23
+
24
+ ## Date input is accepted in whatever form the caller has it
25
+
26
+ Dates arrive from JSON, from attributes, from other components -- as strings as often as `Date` objects. Queries coerce rather than demanding the caller convert first. Option changes made before the calendar has rendered are recorded without forcing a render, so construction order does not matter.
27
+
28
+ - `eventsAt` accepts a date string as readily as a `Date`
29
+ - new Subject({ view: "month", year: 2026, month: 0, day: 15, events: [{ at: new Date(2026, 0, 15, 9, 0, 0), label: "Standup" }], appendTo: document.body }) captures c then c.eventsAt(new Date(2026, 0, 15)) captures byDate then c.eventsAt("2026-01-15T12:00:00") captures byString -> byDate.length === 1 && byString.length === 1 && byString[0].label === "Standup"
30
+ - setting `view` before the calendar has rendered records the value without rendering; the first render then uses it
31
+ - new Subject({ autoRender: false, view: "month" }) captures c then c.options.view = "week" then c.elem.childNodes.length captures beforeRender then c.render() -> beforeRender === 0 && c.options.view === "week" && c.elem.classList.contains("week")
11
32
 
12
33
  ## Day view resolves overlap so simultaneous events don't hide each other
13
34
 
35
+ **browser:** true
36
+
14
37
  - when events occupy the same time slot they share the horizontal space proportionally; events are never stacked invisibly
15
- - do two events at the same time render side by side rather than overlapping?
38
+ - new Subject({ appendTo: document.body, view: "day", year: 2026, month: 0, day: 15, events: [{ at: new Date(2026, 0, 15, 10, 0, 0), duration: 60, label: "one" }, { at: new Date(2026, 0, 15, 10, 0, 0), duration: 60, label: "two" }] }) captures c then await new Promise(r => setTimeout(r, 150)) then Array.from(c.elem.querySelectorAll("div.event")).map(e => e.getBoundingClientRect()) captures boxes -> boxes.length === 2 && boxes[0].width > 0 && boxes[1].width > 0 && boxes[0].right <= boxes[1].left
16
39
 
17
- ## Date navigation handles year boundaries correctly
40
+ ## Week view anchors on the month the week starts in
41
+
42
+ A week that straddles a month boundary belongs to the month it began in, so month-by-month navigation lands on whole weeks instead of splitting one across two screens. The re-anchoring is the exception, not the rule: a week sitting entirely inside its month must be left where it is.
18
43
 
19
- **method:** `navigateBackFromJanuary`
44
+ - the week shown is the one containing the target date; only a week whose first day falls in the previous month re-anchors there
45
+ - new Subject({ view: "month", appendTo: document.body }) captures c then c.setDate(2026, 7, 4) then c.setView("week") -> c.options.month === 7 && c.options.day === 4 && c.elem.textContent.includes("August 2nd - 8th, 2026")
46
+ - new Subject({ view: "month", appendTo: document.body }) captures c then c.setDate(2026, 7, 1) then c.setView("week") -> c.options.month === 6 && c.options.day === 31 && c.elem.textContent.includes("July 26th - August 1st, 2026")
47
+
48
+ ## Date navigation handles year boundaries correctly
20
49
 
21
50
  - navigating backward from January produces December of the prior year; the calendar does not produce invalid dates at month boundaries
22
- - navigateBackFromJanuary() captures result → result.month === 11
51
+ - new Subject({ year: 2026, month: 0, day: 15, view: "month" }) captures c then c.previous() -> c.options.month === 11 && c.options.year === 2025
52
+
53
+ ## An event only gives up horizontal space to events it actually overlaps
54
+
55
+ Sharing the row is a cost paid per collision, not per day: two events at the same hour split the width between them, while an event alone in its hour keeps the full width. Charging every event for the busiest moment of the day would leave most of the column empty.
56
+
57
+ - events that collide are offset from each other; an event with no collision is not offset at all
58
+ - new Subject({ view: "day", year: 2026, month: 0, day: 15, events: [{ at: new Date(2026, 0, 15, 10, 0, 0), duration: 60, label: "one" }, { at: new Date(2026, 0, 15, 10, 0, 0), duration: 60, label: "two" }, { at: new Date(2026, 0, 15, 14, 0, 0), duration: 60, label: "three" }], appendTo: document.body }) captures c then Array.from(c.elem.querySelectorAll("div.event")).map(e => e.style.left) captures lefts -> lefts.length === 3 && lefts[0] !== lefts[1] && lefts[2] === "0px"
@@ -24,6 +24,22 @@ const Right = styled(
24
24
 
25
25
  export const VIEWS = Object.freeze(['day', 'week', 'month']);
26
26
 
27
+ /**
28
+ * Navigation and view-switching header for a Calendar.
29
+ *
30
+ * Renders previous/today/next controls on the left, the calendar's title in the middle, and one
31
+ * button per available view on the right. The toolbar holds no state of its own — every control
32
+ * calls straight through to the calendar it was given.
33
+ * @param {object} [options={}] - Toolbar configuration options
34
+ * @param {Calendar} options.calendar - The calendar these controls drive; its `previous`, `today`,
35
+ * `next` and `setView` methods are called from the buttons. Required — the toolbar constructs
36
+ * without it, but every control throws on press
37
+ * @param {Array<string>} [options.views=VIEWS] - View names to offer, one button each
38
+ * @param {('day'|'week'|'month')} [options.view] - The currently active view; assigning it moves the
39
+ * `pressed` class onto the matching button. Set by the calendar, not usually by a caller
40
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
41
+ * @returns {Toolbar} Toolbar component instance
42
+ */
27
43
  class Toolbar extends Component {
28
44
  static schema = {
29
45
  views: { default: VIEWS },
@@ -6,9 +6,9 @@ Code display that automatically promotes from inline to block when the content s
6
6
 
7
7
  ## Multiline content renders as a block element without the caller specifying it
8
8
 
9
- - the component detects newlines at render time and promotes accordingly
9
+ - content spanning multiple lines renders as a block element and single-line content renders inline; the caller passes code and never chooses between the two
10
10
  - does code with newlines render as a block (pre) rather than an inline element?
11
- - does single-line code render inline?
11
+ - new Subject({ code: "one line", multiline: "auto", copyButton: false, language: "javascript" }) captures c -> c.elem.tagName === "CODE"
12
12
 
13
13
  ## Language class enables syntax highlighting libraries to identify the element
14
14
 
@@ -18,4 +18,4 @@ Code display that automatically promotes from inline to block when the content s
18
18
  ## Copy confirms success visually - clipboard writes can fail silently
19
19
 
20
20
  - the copy button shows a notification on success so the user knows the copy worked rather than discovering it failed on paste
21
- - does a successful copy show the user a confirmation?
21
+ - new Subject({ code: "a\nb", copyButton: true, appendTo: document.body }) captures c then Object.defineProperty(window, "isSecureContext", { value: true, configurable: true }) then Object.defineProperty(navigator, "clipboard", { value: { writeText: () => {} }, configurable: true }) then c.elem.querySelector("button") captures btn then btn.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then btn.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) then await new Promise(r => setTimeout(r, 40)) -> document.body.textContent.includes("Copied text to clipboard!")
@@ -7,15 +7,38 @@ HSL-based color picker where hue and saturation/lightness are controlled by sepa
7
7
  ## Multiple input formats are accepted without the caller normalizing first
8
8
 
9
9
  - color strings, color objects, and the sentinel 'random' all produce a valid picker state without error
10
- - does initializing with a color string produce a valid picker state?
11
- - does initializing with value 'random' produce a valid picker state?
10
+ - new Subject({ value: "#3366cc" }) captures p -> CSS.supports("color", String(p.options.value)) && p.hue >= 0
11
+ - new Subject({ value: "random" }) captures p -> CSS.supports("color", String(p.options.value)) && String(p.options.value) !== "random"
12
12
 
13
13
  ## Dragging outside the picker area does not produce out-of-range colors
14
14
 
15
+ **browser:** true
16
+
15
17
  - the position is clamped to the picker's bounds regardless of where the pointer goes; callers receive valid color values even during aggressive drag behavior
16
- - does dragging well outside the saturation area still produce a valid color value?
18
+ - new Subject({ appendTo: document.body, value: "#3366cc" }) captures p then await new Promise(r => setTimeout(r, 80)) then p.pickerArea.elem captures area then area.getBoundingClientRect() captures box then area.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: box.left + 5, clientY: box.top + 5 })) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: box.right + 4000, clientY: box.bottom + 4000 })) then await new Promise(r => setTimeout(r, 140)) then p.pickerIndicator.elem.getBoundingClientRect() captures ind -> ind.top - box.top >= box.height - 10 && CSS.supports("color", String(p.options.value))
17
19
 
18
20
  ## Hue and saturation areas have independent pointer tracking
19
21
 
22
+ **browser:** true
23
+
20
24
  - interaction with the hue bar does not affect the saturation/lightness position and vice versa
21
- - does adjusting the hue leave the saturation value unchanged?
25
+ - new Subject({ appendTo: document.body, value: "#3366cc" }) captures p then await new Promise(r => setTimeout(r, 100)) then p.pickerIndicator.elem.getBoundingClientRect() captures svBefore then p.hue captures hueBefore then p.hueArea.elem.getBoundingClientRect() captures hb then p.hueArea.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: hb.left + 20, clientY: hb.top + 15 })) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: hb.left + hb.width * 0.4, clientY: hb.top + 15 })) then await new Promise(r => setTimeout(r, 100)) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: hb.left + hb.width * 0.7, clientY: hb.top + 15 })) then await new Promise(r => setTimeout(r, 100)) then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: hb.left + hb.width * 0.7, clientY: hb.top + 15 })) then await new Promise(r => setTimeout(r, 100)) then p.pickerIndicator.elem.getBoundingClientRect() captures svAfter -> Math.round(p.hue) !== Math.round(hueBefore) && Math.abs(svAfter.left - svBefore.left) < 1 && Math.abs(svAfter.top - svBefore.top) < 1
26
+ - new Subject({ appendTo: document.body, value: "#3366cc" }) captures p then await new Promise(r => setTimeout(r, 100)) then p.pickerIndicator.elem.getBoundingClientRect() captures svBefore then p.hue captures hueBefore then p.pickerArea.elem.getBoundingClientRect() captures pb then p.pickerArea.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: pb.left + 10, clientY: pb.top + 10 })) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: pb.left + pb.width * 0.7, clientY: pb.top + pb.height * 0.7 })) then await new Promise(r => setTimeout(r, 100)) then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: pb.left + pb.width * 0.7, clientY: pb.top + pb.height * 0.7 })) then await new Promise(r => setTimeout(r, 100)) then p.pickerIndicator.elem.getBoundingClientRect() captures svAfter -> Math.round(p.hue) === Math.round(hueBefore) && Math.abs(svAfter.top - svBefore.top) > 1
27
+
28
+ ## Hue is remembered when the colour alone cannot carry it
29
+
30
+ A colour string does not always determine a slider position. Fully desaturated colours have no hue to read back, and the conversion wraps 360 to 0 -- the same red, but the opposite end of the slider. In both cases the picker keeps the hue it already had, so the handle stays where the user put it. An empty value is not a colour at all and changes nothing.
31
+
32
+ - a colour that reports hue 0 while the slider sits near 360 keeps its position rather than jumping to the other end
33
+ - new Subject({ value: "hsl(355, 80%, 50%)", appendTo: document.body }) captures p then Math.round(p.hue) captures before then p.options.value = "hsl(0, 80%, 50%)" -> before === 355 && Math.round(p.hue) === 355
34
+ - assigning an empty value leaves the picker's state as it was
35
+ - new Subject({ value: "#3366cc", appendTo: document.body }) captures p then Math.round(p.hue) captures hueBefore then p.elem.style.backgroundColor captures bgBefore then p.options.value = "" -> hueBefore === 220 && Math.round(p.hue) === 220 && p.elem.style.backgroundColor === bgBefore
36
+
37
+ ## The `random` swatch shows that it is a choice, not a colour
38
+
39
+ Every other swatch can paint itself with the colour it selects. `random` has no colour to show, so painting it any single colour would misrepresent what pressing it does -- it carries the rainbow treatment instead.
40
+
41
+ - a `random` entry renders as the rainbow swatch while ordinary entries paint their own colour
42
+ - new Subject({ swatches: ["#ff0000", "random"], appendTo: document.body }) captures p then Array.from(p.elem.querySelectorAll("*")) captures parts -> parts.filter(e => e.className.includes("rainbow")).length === 1 && parts.some(e => e.style.backgroundColor !== "" && !e.className.includes("rainbow"))
43
+ - the `random` swatch is left unpainted so the rainbow treatment shows through; a colour swatch paints itself with its own colour
44
+ - new Subject({ swatches: ["#ff0000", "random"], appendTo: document.body }) captures p then Array.from(p.elem.querySelectorAll("*")) captures parts then parts.filter(e => e.className.includes("rainbow")) captures rainbow -> rainbow.length === 1 && rainbow[0].style.backgroundColor === "" && parts.some(e => !e.className.includes("rainbow") && (e.style.backgroundColor === "#ff0000" || e.style.backgroundColor === "rgb(255, 0, 0)"))
@@ -18,3 +18,17 @@ Native `<dialog>` element wrapper. The decision to use the native element rather
18
18
 
19
19
  - the structural elements are created once in `build()` and persist across option updates; assigning a new `header` value changes the header's content, not the dialog's structure
20
20
  - does updating the header option change the header's content without replacing the dialog structure?
21
+
22
+ ## Buttons are declared in the shorthand that fits
23
+
24
+ A dialog's buttons are usually just labels, so a string is enough; a button that needs more than a label carries its own options object. Both forms sit in the same array rather than in separate options.
25
+
26
+ - a button entry may be a label string or an options object, and both render as buttons in order
27
+ - new Subject({ buttons: ["OK", { textContent: "Cancel", addClass: "cancel" }], appendTo: document.body }) captures d then Array.from(d.elem.querySelectorAll("button")) captures btns -> btns.map(b => b.textContent).join() === "OK,Cancel" && btns[1].classList.contains("cancel")
28
+ - a dialog exposes the region its content belongs in, so callers append there rather than to the frame
29
+ - new Subject({ appendTo: document.body }) captures d -> !!d.body && d.body.elem.tagName === "DIV"
30
+
31
+ - a dialog with no explicit parent attaches to the document body, since a modal belongs to the page rather than to its opener
32
+ - new Subject({}) captures d -> d.elem.parentElement === document.body
33
+ - a numeric `openOnRender` is the delay in milliseconds; `true` takes the small default instead of waiting
34
+ - new Subject({ openOnRender: 400 }) captures slow then new Subject({ openOnRender: true }) captures quick then await new Promise(r => setTimeout(r, 120)) then slow.elem.open captures slowEarly then quick.elem.open captures quickEarly then await new Promise(r => setTimeout(r, 400)) -> slowEarly === false && quickEarly === true && slow.elem.open === true
@@ -7,15 +7,45 @@ Declarative form from an array of input configurations. The key design decision
7
7
  ## Label text falls back to the field key - no separate label required for obvious fields
8
8
 
9
9
  - if an input config omits `label`, the key name becomes the label
10
- - does an input with no label option display the key name as its label?
10
+ - new Subject({ inputs: [{ key: "emailAddress" }], appendTo: document.body }) captures f -> f.elem.querySelector("label").textContent.toLowerCase().replace(/\s/g, "") === "emailaddress"
11
11
 
12
12
  ## hasErrors validates all inputs simultaneously and returns a boolean
13
13
 
14
14
  - `hasErrors()` runs all validations and returns true if any failed, false if all passed; false means submittable
15
15
  - does hasErrors return false when all inputs are valid?
16
- - does hasErrors return true when an input's validation fails?
16
+ - new Subject({ inputs: [{ key: "name", validations: [[v => !!v, "required"]] }], appendTo: document.body }) captures f -> f.hasErrors() === true
17
17
 
18
18
  ## Form data is live - changes are visible before any submit action
19
19
 
20
20
  - each input's value feeds the shared `data` context as it changes
21
- - does typing in an input field update the form's data context before any submit?
21
+ - new Subject({ inputs: [{ key: "name" }], appendTo: document.body }) captures f then f.elem.querySelector("input") captures input then input.value = "typed" then input.dispatchEvent(new Event("change", { bubbles: true })) -> f.options.data.name === "typed"
22
+
23
+ ## A conditional field is absent until its condition holds, and so are its rules
24
+
25
+ A field that does not apply yet must not block submission, so hiding a field and excluding it from validation are the same decision rather than two -- otherwise a form becomes unsubmittable because of a rule on a question nobody was asked. Conditions are re-evaluated whenever form data changes, not only at build.
26
+
27
+ - a field whose condition is unmet is hidden and its validation is not enforced; satisfying the condition reveals it and its rules start to apply
28
+ - new Subject({ inputs: [{ key: "wantsPet", type: "checkbox", label: "Wants pet" }, { key: "petName", label: "Pet name", condition: d => d.wantsPet, validate: v => (v ? undefined : "required") }], appendTo: document.body }) captures f then f.hasErrors() captures errorsWhileHidden then Array.from(f.elem.querySelectorAll("label")).map(l => l.parentElement.style.display).join() captures displayWhileHidden then f.options.data.wantsPet = true then await new Promise(r => setTimeout(r, 80)) -> errorsWhileHidden === false && displayWhileHidden === ",none" && Array.from(f.elem.querySelectorAll("label")).map(l => l.parentElement.style.display).join() === "," && f.hasErrors() === true
29
+
30
+ ## A field that becomes available is announced, not just shown
31
+
32
+ A field appearing partway down a form is easy to miss and impossible to see for a screen reader user, who has already read past that point. The form says which fields became available rather than leaving the change silent.
33
+
34
+ - revealing a conditional field announces it by name
35
+ - new Subject({ inputs: [{ key: "wantsPet", type: "checkbox", label: "Wants pet" }, { key: "petName", label: "Pet name", condition: d => d.wantsPet, validate: v => (v ? undefined : "required") }], appendTo: document.body }) captures f then f.options.data.wantsPet = true then await new Promise(r => setTimeout(r, 80)) -> f._announcer.textContent === "Pet name now available"
36
+
37
+ ## A checkbox is labelled beside itself, not above
38
+
39
+ A checkbox is narrow and its label reads as a sentence with it, so the label sits inline; every other field takes the default stacked arrangement. The form decides this from the field's type rather than asking the caller to specify layout per field.
40
+
41
+ - a checkbox field's label is laid out inline while other fields keep the default variant
42
+ - new Subject({ inputs: [{ key: "wantsPet", type: "checkbox", label: "Wants pet" }, { key: "petName", label: "Pet name", condition: d => d.wantsPet, validate: v => (v ? undefined : "required") }], appendTo: document.body }) captures f then Array.from(f.elem.querySelectorAll("label")).map(l => l.parentElement.className.includes("variant-inline")) captures inline -> inline[0] === true && inline[1] === false
43
+
44
+ ## Reassigning `inputs` rebuilds the fields around the data that is already there
45
+
46
+ A form whose field list changes -- a step added, a section swapped -- must not discard what the user has already typed, so the rebuild replaces the fields while the data store survives it. The listener that watches data for conditions is only attached when there is a condition to evaluate, so a form without any adds no subscription to clean up.
47
+
48
+ - assigning a new `inputs` list renders the new fields and keeps the values already collected
49
+ - new Subject({ inputs: [{ key: "a", label: "A" }], appendTo: document.body }) captures f then f.options.data.a = "kept" then Array.from(f.elem.querySelectorAll("label")).length captures beforeCount then f.options.inputs = [{ key: "a", label: "A" }, { key: "b", label: "B" }] then await new Promise(r => setTimeout(r, 40)) -> beforeCount === 1 && f.elem.querySelectorAll("label").length === 2 && f.options.data.a === "kept"
50
+ - a form with no conditional fields registers no data subscription; one with them does
51
+ - new Subject({ inputs: [{ key: "x", label: "X" }], appendTo: document.body }) captures plain then new Subject({ inputs: [{ key: "x", label: "X" }, { key: "y", label: "Y", condition: d => d.x }], appendTo: document.body }) captures conditional -> !plain.cleanup.conditions && !!conditional.cleanup.conditions
@@ -7,7 +7,8 @@ import { Component } from '../../Component';
7
7
  * Handles class management for dynamic icon changes and supports all FontAwesome icon variants.
8
8
  * @param {object} [options={}] - Icon configuration options
9
9
  * @param {string} [options.icon] - FontAwesome icon name (without 'fa-' prefix)
10
- * @param {string} [options.animation] - FontAwesome animation name (without 'fa-' prefix)
10
+ * @param {string} [options.animation] - FontAwesome animation name (without 'fa-' prefix), run on the whole element
11
+ * @param {string} [options.iconAnimation] - FontAwesome animation name (without 'fa-' prefix), run on the glyph alone
11
12
  * @param {string} [options.content] - Text content to display alongside icon
12
13
  * @param {string} [options.textContent] - Alternative text content property
13
14
  * @param {...(Component|HTMLElement|string)} children - Child elements to append
@@ -25,6 +26,11 @@ export default class Icon extends Component {
25
26
  this._refreshIcon();
26
27
  },
27
28
  },
29
+ iconAnimation: {
30
+ set() {
31
+ this._refreshIcon();
32
+ },
33
+ },
28
34
  content: {
29
35
  set(value, next) {
30
36
  next(value);
@@ -41,8 +47,9 @@ export default class Icon extends Component {
41
47
 
42
48
  _refreshIcon() {
43
49
  this.removeClass(/\bfa-\S+\b/g);
50
+ this.removeClass(/\bicon-animation-\S+\b/g);
44
51
 
45
- const { icon, animation, content, textContent } = this.options;
52
+ const { icon, animation, iconAnimation, content, textContent } = this.options;
46
53
 
47
54
  if (icon || animation) {
48
55
  this.addClass(
@@ -51,8 +58,22 @@ export default class Icon extends Component {
51
58
  );
52
59
  }
53
60
 
61
+ // A FontAwesome animation class animates whatever element carries it. On a bare Icon the
62
+ // element and the glyph are the same box, so that reads correctly; on a subclass with a
63
+ // label — Button, Link — it takes the whole control with it. iconAnimation is the same set
64
+ // of animations aimed at the :before that draws the glyph, so the two are separable.
65
+ if (iconAnimation) this.addClass(`icon-animation-${iconAnimation}`);
66
+
54
67
  const interactive = ['button', 'a', 'input', 'select', 'textarea'].includes(this.elem.tagName.toLowerCase());
55
- const labeled = this.elem.hasAttribute('aria-label') || this.elem.hasAttribute('aria-labelledby');
68
+ // The options are consulted as well as the element. This runs while options are still being
69
+ // applied, so a caller-supplied `aria-label` is not on the element yet -- reading only the
70
+ // element concluded the icon was unlabelled and hid it, leaving an icon carrying both a label
71
+ // and `aria-hidden="true"`, which is the one combination that helps nobody.
72
+ const labeled =
73
+ this.elem.hasAttribute('aria-label') ||
74
+ this.elem.hasAttribute('aria-labelledby') ||
75
+ this.options['aria-label'] !== undefined ||
76
+ this.options['aria-labelledby'] !== undefined;
56
77
  if (!interactive && !content && !textContent && !labeled) {
57
78
  this.elem.setAttribute('aria-hidden', 'true');
58
79
  } else {
@@ -12,10 +12,28 @@ FontAwesome icon wrapper that manages FA class state as options rather than raw
12
12
  ## Icon-only and icon-with-text render differently without the caller specifying which mode
13
13
 
14
14
  - when there is no text content, the element receives the `icon` class, which CSS uses for icon-only layout; when text accompanies the icon, that class is absent
15
- - does an Icon constructed without text receive the icon-only class?
16
- - does an Icon constructed with text not receive the icon-only class?
15
+ - new Subject({ icon: "star" }) captures i -> i.elem.classList.contains("icon") === true
16
+ - new Subject({ icon: "star", textContent: "hi" }) captures i -> i.elem.classList.contains("icon") === false
17
17
 
18
18
  ## Animation and icon are independent options that compose
19
19
 
20
20
  - setting `animation` applies an animation class independently of `icon`; an Icon with both options active carries both classes simultaneously; they do not interfere
21
- - does an Icon with both icon and animation options active carry both classes at the same time?
21
+ - new Subject({ icon: "star", animation: "spin" }) captures i -> i.elem.classList.contains("fa-star") && i.elem.classList.contains("fa-spin")
22
+
23
+ ## Animating the glyph is a separate choice from animating the element
24
+
25
+ A FontAwesome animation class animates whatever element carries it. On a bare Icon that reads correctly, because the element and the glyph are the same box. On a subclass that also renders a label (Button, Link) it takes the whole control with it, background and text included, which is rarely what a caller asking for a spinner meant. `animation` keeps that element-level behavior; `iconAnimation` runs the same set of animations against the `:before` that draws the glyph. The two are independent, so a caller can run either or both.
26
+
27
+ - setting `iconAnimation` never applies the element-level FA animation class, and setting `animation` never applies the glyph one
28
+ - new Subject({ icon: "spinner", iconAnimation: "spin" }) captures i -> i.elem.classList.contains("icon-animation-spin") && !i.elem.classList.contains("fa-spin")
29
+ - new Subject({ icon: "spinner", animation: "beat", iconAnimation: "spin" }) captures i -> i.elem.classList.contains("fa-beat") && i.elem.classList.contains("icon-animation-spin")
30
+
31
+ - an icon animation decorates the glyph and says nothing about layout; it never makes the element icon-only
32
+ - new Subject({ icon: "spinner", iconAnimation: "spin", textContent: "Saving" }) captures i -> i.elem.classList.contains("icon") === false
33
+
34
+ ## A decorative icon is hidden from assistive technology; a meaningful one is not
35
+
36
+ An icon with nothing but a glyph carries no information a screen reader can convey, and announcing its class name is worse than silence. An icon that has text beside it, carries its own label, or is itself the control is content, and must stay announceable.
37
+
38
+ - an icon with no text, no label and no interactive role is marked `aria-hidden`; text, a label, or an interactive tag each keep it announced
39
+ - new Subject({ icon: "star", appendTo: document.body }) captures bare then new Subject({ icon: "star", textContent: "Save", appendTo: document.body }) captures withText then new Subject({ icon: "star", "aria-label": "Save", appendTo: document.body }) captures labelled then new Subject({ icon: "star", tag: "button", appendTo: document.body }) captures control -> bare.elem.getAttribute("aria-hidden") === "true" && withText.elem.getAttribute("aria-hidden") === null && labelled.elem.getAttribute("aria-hidden") === null && control.elem.getAttribute("aria-hidden") === null
@@ -7,22 +7,46 @@ Input element that infers its type from the value it receives rather than requir
7
7
  ## Type is communicated by the value, not by a separate option
8
8
 
9
9
  - callers pass a value; the input determines its type from that value's type
10
- - does a number value produce an input that accepts numeric entry?
11
- - does a boolean value produce an input that represents a checked/unchecked state?
10
+ - new Subject({ value: 7 }) captures i -> i.elem.type === "number"
11
+ - new Subject({ value: true }) captures i -> i.elem.type === "checkbox"
12
12
 
13
13
  ## isDirty reflects whether the value has changed from its initial state
14
14
 
15
15
  - the initial value is recorded at construction; `isDirty` answers "has the user changed this?" without external tracking
16
16
  - does isDirty return false when the value matches the initial value?
17
- - does isDirty return true after the value has been changed?
17
+ - new Subject({ value: "start" }) captures i then i.isDirty captures before then i.options.value = "changed" -> before === false && i.isDirty === true
18
18
 
19
19
  ## Validation errors are surfaced as element state, not return values
20
20
 
21
21
  - when a validation fails, the element enters an error state that CSS can target; passing clears it
22
- - does a failing validation leave the input in an error state?
23
- - does a passing validation clear the error state?
22
+ - new Subject({ value: "", validations: [[v => !!v, "required"]], appendTo: document.body }) captures i then i.validate() -> i.elem.classList.contains("validation-errors")
23
+ - new Subject({ value: "", validations: [[v => !!v, "required"]], appendTo: document.body }) captures i then i.validate() then i.options.value = "filled" then i.validate() -> i.elem.classList.contains("validation-errors") === false
24
24
 
25
25
  ## Textarea height follows content automatically
26
26
 
27
+ **browser:** true
28
+
27
29
  - when `tag: 'textarea'`, the element resizes to fit its content without the caller managing height
28
- - does a textarea grow taller when its content exceeds its current height?
30
+ - new Subject({ tag: "textarea", appendTo: document.body, value: "one line" }) captures i then i.elem.offsetHeight captures before then i.elem.value = "a\nb\nc\nd\ne\nf\ng\nh" then i.elem.dispatchEvent(new Event("input", { bubbles: true })) then await new Promise(r => setTimeout(r, 60)) -> i.elem.offsetHeight > before
31
+
32
+ ## Sizing and checkbox state read from the same `value`/`height` options
33
+
34
+ `height` is a number when the caller is thinking in rows and a string when they are thinking in CSS, so the number is interpreted as text rows and anything else is passed to the style as written. A checkbox has no useful text value either, so for that type `value` drives the checked state instead.
35
+
36
+ - a numeric `height` is interpreted as rows of text; a string is used as the CSS length verbatim
37
+ - new Subject({ tag: "textarea", height: 3, appendTo: document.body }) captures rows then new Subject({ tag: "textarea", height: "40px", appendTo: document.body }) captures css -> rows.elem.style.height === "4em" && css.elem.style.height === "40px"
38
+ - for `type: 'checkbox'` the value sets the checked state rather than the element's text value
39
+ - new Subject({ type: "checkbox", value: true, appendTo: document.body }) captures on then new Subject({ type: "checkbox", value: false, appendTo: document.body }) captures off -> on.elem.checked === true && off.elem.checked === false
40
+ - an input with no `validations` option has an empty list rather than none, so callers can append without checking
41
+ - new Subject({ appendTo: document.body }) captures i -> Array.isArray(i.options.validations) && i.options.validations.length === 0
42
+
43
+ ## Syntax highlighting is opt-in, and the element's defaults follow its tag
44
+
45
+ A `language` on its own says what the content is, not that it should be coloured -- highlighting is a separate decision because it costs a stylesheet and changes how the field reads. The text-input defaults are likewise scoped to the tags they make sense for: a `select` is an Input but not a text field.
46
+
47
+ - `language` alone adds no highlighting class; `syntaxHighlighting` is what turns it on
48
+ - new Subject({ language: "js", appendTo: document.body }) captures off then new Subject({ language: "js", syntaxHighlighting: true, appendTo: document.body }) captures on -> off.elem.className.includes("language-js") === false && on.elem.classList.contains("language-js")
49
+ - text-input defaults apply to `input` and `textarea` and not to other tags an Input can take
50
+ - new Subject({ appendTo: document.body }) captures text then new Subject({ tag: "select", appendTo: document.body }) captures select -> text.elem.getAttribute("autocomplete") === "off" && select.elem.getAttribute("autocomplete") === null
51
+ - `validate()` returns the messages it produced and marks the element invalid; a value that passes returns nothing and clears the mark
52
+ - new Subject({ validations: [[v => v === "ok", "must be ok"]], value: "no", appendTo: document.body }) captures bad then bad.validate() captures errors then new Subject({ validations: [[v => v === "ok", "must be ok"]], value: "ok", appendTo: document.body }) captures good then good.validate() captures none -> errors.join() === "must be ok" && bad.elem.getAttribute("aria-invalid") === "true" && none === undefined && good.elem.getAttribute("aria-invalid") === null