@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
@@ -6,16 +6,30 @@ Sortable data table where sort state is explicit options. The design decision: `
6
6
 
7
7
  ## Sort state lives in options - externally readable and settable
8
8
 
9
- - clicking a column header updates `sortProperty` and `sortDirection` as regular options; external code can read or set sort state without querying the DOM
10
- - does clicking a sortable column update sortProperty to that column's key?
11
- - does clicking an already-sorted column toggle sortDirection?
9
+ - clicking a column header updates `sortProperty` and `sortDirection` as regular options; external code can read or set sort state without querying the DOM. The first click on a column sorts it descending; a second click on the same column toggles to ascending -- sorting only moves between columns, there is no click back to unsorted
10
+ - new Subject({ columns: [{ key: "name", content: "Name", sort: true }], data: [{ name: "b" }, { name: "a" }] }) captures t then t.elem.querySelector("th").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) -> t.options.sortProperty === "name" && t.options.sortDirection === "desc"
11
+ - new Subject({ columns: [{ key: "name", content: "Name", sort: true }], data: [{ name: "b" }, { name: "a" }] }) captures t then t.elem.querySelector("th").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then t.elem.querySelector("th").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) -> t.options.sortDirection === "asc"
12
12
 
13
13
  ## Custom cell renderers receive the full row context, not just the cell value
14
14
 
15
15
  - a column's `dataColumn` function receives `{ column, rowData, table }` so cells can cross-reference other columns or interact with the table
16
- - does a dataColumn function receive the complete rowData object for its row?
16
+ - new Array() captures seen then new Subject({ columns: [{ key: "name", dataColumn: arg => { seen.push(arg); return {}; } }], data: [{ name: "x" }] }) captures t -> seen.length === 1 && seen[0].rowData.name === "x" && seen[0].table === t && seen[0].column.key === "name"
17
17
 
18
18
  ## Footer aligns with data columns, not with DOM order
19
19
 
20
20
  - footer cells are positioned by column key; adding or reordering columns does not misalign footer labels from data
21
- - does the footer appear in the correct columns regardless of column order?
21
+ - new Subject({ columns: [{ key: "name", content: "Name" }, { key: "amount", content: "Amount" }], data: [{ name: "x", amount: 1 }], footer: [{ key: "amount", content: "AMT" }, { key: "name", content: "NM" }] }) captures t then Array.from(t.elem.querySelectorAll("tfoot td")).map(c => c.textContent) captures cells -> cells.join(",") === "NM,AMT"
22
+
23
+ ## A sortable column advertises its state, in the markup and in the icon
24
+
25
+ Sort state is not only a visual affordance: a reader using a screen reader needs to know which column the order comes from and which way it runs, which is what `aria-sort` carries. A sortable column that is not the sorted one shows the neutral affordance rather than nothing, so it reads as available rather than absent.
26
+
27
+ - every sortable column starts at `aria-sort="none"` with the neutral icon; the sorted column reports its direction and shows a directional icon while the others stay neutral
28
+ - new Subject({ columns: [{ key: "name", label: "Name", sort: true }, { key: "age", label: "Age", sort: true }], data: [{ name: "b", age: 2 }, { name: "a", age: 1 }], appendTo: document.body }) captures t then Array.from(t.elem.querySelectorAll("th")).map(h => h.getAttribute("aria-sort")) captures initial then t.elem.querySelector("th") captures first then first.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then first.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) then Array.from(t.elem.querySelectorAll("th")).map(h => h.getAttribute("aria-sort")) captures afterFirst then first.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then first.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) -> initial.join() === "none,none" && afterFirst.join() === "descending,none" && Array.from(t.elem.querySelectorAll("th")).map(h => h.getAttribute("aria-sort")).join() === "ascending,none"
29
+ - the sorted column's icon points the way the order runs; a sortable column that is not sorted keeps the neutral icon
30
+ - new Subject({ columns: [{ key: "name", label: "Name", sort: true }, { key: "age", label: "Age", sort: true }], data: [{ name: "b", age: 2 }, { name: "a", age: 1 }], appendTo: document.body }) captures t then Array.from(t.elem.querySelectorAll("th")).map(h => Array.from(h.querySelectorAll("*")).flatMap(e => Array.from(e.classList).filter(c => c.startsWith("fa-sort"))).join()) captures initial then t.elem.querySelector("th") captures first then first.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then first.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) then Array.from(t.elem.querySelectorAll("th")).map(h => Array.from(h.querySelectorAll("*")).flatMap(e => Array.from(e.classList).filter(c => c.startsWith("fa-sort"))).join()) captures sorted -> initial.join("|") === "fa-sort|fa-sort" && sorted.join("|") === "fa-sort-up|fa-sort"
31
+ - a data set that is absent renders an empty table rather than throwing
32
+ - new Subject({ columns: [{ key: "a", label: "A" }], data: undefined, appendTo: document.body }) captures missing then new Subject({ columns: [{ key: "a", label: "A" }], data: null, appendTo: document.body }) captures empty -> missing.elem.querySelectorAll("tbody tr").length === 0 && empty.elem.querySelectorAll("tbody tr").length === 0
33
+
34
+ - option changes made before the first render are recorded without rendering; the first render then reflects them
35
+ - new Array() captures sorted then new Subject({ autoRender: false, columns: [{ key: "a", label: "A", sort: true }], data: [{ a: 1 }], onSort: (p, d) => sorted.push(d) }) captures t then t.options.sortDirection = "asc" then sorted.length captures beforeRender then t.render() then t.options.sortDirection = "desc" -> beforeRender === 0 && sorted.join() === "desc"
@@ -52,6 +52,20 @@ const StyledComponent = styled(
52
52
  `,
53
53
  );
54
54
 
55
+ /**
56
+ * A single tag chip within a TagList.
57
+ *
58
+ * Renders its text alongside a remove button, which destroys the tag. In read-only mode the button
59
+ * is omitted and the chip is display-only.
60
+ * @param {object} [options={}] - Tag configuration options
61
+ * @param {string} [options.tag='li'] - HTML tag for the chip element
62
+ * @param {number} [options.tabIndex=0] - Tab order for the chip
63
+ * @param {boolean} [options.readOnly=false] - Omits the remove button and applies the readOnly class
64
+ * @param {string} [options.textContent] - The tag's text; also mirrored onto the element as
65
+ * `data-value`, which is what TagList reads back when collecting current tags
66
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
67
+ * @returns {Tag} Tag component instance
68
+ */
55
69
  class Tag extends StyledComponent {
56
70
  static schema = {
57
71
  tag: { default: 'li' },
@@ -105,21 +105,3 @@ class TagList extends StyledComponent {
105
105
  }
106
106
 
107
107
  export default TagList;
108
-
109
- // Zero-arg scenarios for LLD verification
110
- export const duplicateRejected = () => {
111
- const tagList = new TagList({ tags: ['hello'], autoRender: false });
112
- tagList.render();
113
- const before = tagList.elem.children.length;
114
- tagList.tagInput.elem.value = 'hello';
115
- tagList.tagInput.elem.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
116
- return tagList.elem.children.length === before;
117
- };
118
-
119
- export const inputIsLastAfterAdd = () => {
120
- const tagList = new TagList({ tags: [], autoRender: false });
121
- tagList.render();
122
- tagList.tagInput.elem.value = 'new-tag';
123
- tagList.tagInput.elem.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
124
- return tagList.elem.lastElementChild === tagList.addTag.elem;
125
- };
@@ -8,23 +8,28 @@ Editable tag collection. The key decision: read-only mode removes the editing in
8
8
 
9
9
  - `readOnly: true` omits the input and add button entirely; `readOnly: false` includes the full editing interface
10
10
  - does a readOnly TagList contain no input element?
11
- - does a non-readOnly TagList contain both an input and an add button?
11
+ - new Subject({ readOnly: false, tags: [] }) captures t -> !!t.elem.querySelector("input") && !!t.elem.querySelector("button")
12
12
 
13
13
  ## Duplicate tags are silently rejected - adding them does nothing
14
14
 
15
- **method:** `duplicateRejected`
16
-
17
15
  - attempting to add a tag that already exists leaves the list unchanged; there is no error
18
- - duplicateRejected() → true
16
+ - new Subject({ tags: ["hello"], autoRender: false }) captures t then t.render() then t.elem.children.length captures before then t.tagInput.elem.value = "hello" then t.tagInput.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })) -> t.elem.children.length === before
19
17
 
20
18
  ## The add interface stays at the end as tags are added and removed
21
19
 
22
- **method:** `inputIsLastAfterAdd`
23
-
24
20
  - new tags are inserted before the add-tag input; the input remains the last element automatically
25
- - inputIsLastAfterAdd() → true
21
+ - new Subject({ tags: [], autoRender: false }) captures u then u.render() then u.tagInput.elem.value = "new-tag" then u.tagInput.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })) -> u.elem.lastElementChild === u.addTag.elem
26
22
 
27
23
  ## Cleanup removes the editing interface completely on destroy
28
24
 
29
25
  - the input, button, and popover are destroyed with the component, removing event listeners
30
26
  - does destroying the TagList remove its editing interface from the DOM?
27
+
28
+ ## Enter commits a tag; nothing else does, and nothing empty or repeated gets in
29
+
30
+ Typing is not committing -- the field has to stay usable while a tag is being spelled, so only Enter turns what is typed into a tag. What arrives is then filtered on the two grounds that are always wrong: nothing at all, and something already in the list.
31
+
32
+ - Enter adds the typed tag; any other key leaves the list as it was
33
+ - new Subject({ tags: ["one"], appendTo: document.body }) captures t then t.tagInput.elem.value = "two" then t.tagInput.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })) then Array.from(t.elem.querySelectorAll("li[data-value]")).map(li => li.dataset.value) captures afterEnter then t.tagInput.elem.value = "three" then t.tagInput.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "a", bubbles: true })) -> afterEnter.join() === "one,two" && Array.from(t.elem.querySelectorAll("li[data-value]")).map(li => li.dataset.value).join() === "one,two"
34
+ - a blank or whitespace-only entry is not added, and neither is one already present
35
+ - new Subject({ tags: ["one"], appendTo: document.body }) captures t then t.tagInput.elem.value = " " then t.tagInput.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })) then Array.from(t.elem.querySelectorAll("li[data-value]")).length captures afterBlank then t.tagInput.elem.value = "one" then t.tagInput.elem.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })) -> afterBlank === 1 && t.elem.querySelectorAll("li[data-value]").length === 1
@@ -6,17 +6,25 @@ Positioned popover for explanatory text with nine validated position presets. Th
6
6
 
7
7
  ## Invalid position fails immediately, not on first show
8
8
 
9
+ Surfacing misuse at development time beats producing misaligned tooltips that only appear during interaction.
10
+
9
11
  - there are nine valid position values; anything else throws at assignment time, not at show time
10
- - this surfaces misuse at development time rather than producing misaligned tooltips that only appear during interaction
11
12
  - does passing an invalid position value throw immediately?
12
13
 
13
14
  ## Changing position removes the previous position class
14
15
 
16
+ Callers can reassign `position` freely without cleaning up after the previous value.
17
+
15
18
  - only one position class is active at a time; switching position replaces, not accumulates
16
- - callers can safely reassign position without manual cleanup
17
19
  - does changing position from one value to another leave only the new position class active?
18
20
 
19
21
  ## Show/hide animation is declared in CSS, not scheduled in JavaScript
20
22
 
21
- - the transition is controlled by `:popover-open` and `@starting-style`; the component applies position classes and the platform animates
22
- - this means animation timing is a CSS concern, not a component lifecycle concern
23
+ The transition is controlled by `:popover-open` and `@starting-style`. The component's only part is applying position classes; the platform animates. Animation timing is therefore a CSS concern rather than a component lifecycle one, which is why nothing here schedules it or waits on it.
24
+
25
+ ## A tooltip is always identifiable as one
26
+
27
+ The `tooltip` class is what the stylesheet and any consumer selector key off, so it is added rather than assigned -- classes the caller passes come along with it instead of replacing it.
28
+
29
+ - every tooltip carries the `tooltip` class, and caller-supplied classes are added alongside it
30
+ - new Subject({ textContent: "tip", addClass: "mine", appendTo: document.body }) captures t -> t.elem.classList.contains("tooltip") && t.elem.classList.contains("mine")
@@ -19,3 +19,17 @@ Component that adds a tooltip to whatever it wraps. The Tooltip is created durin
19
19
 
20
20
  - when the TooltipWrapper is destroyed, the Tooltip component is also destroyed and removed from the DOM
21
21
  - does destroying the wrapper also remove the tooltip from the DOM?
22
+
23
+ ## The tooltip option takes a string or a full set of options
24
+
25
+ Most tooltips are a line of text, so a string is the common case and stays the shortest thing to write. Anything more -- extra classes, placement, markup -- is the same option carrying a full options object instead.
26
+
27
+ - a string `tooltip` becomes the tooltip's text; an object is used as the tooltip's own options
28
+ - new Subject({ tooltip: "plain", appendTo: document.body }) captures s then new Subject({ tooltip: { textContent: "rich", addClass: "fancy" }, appendTo: document.body }) captures o then s.elem.querySelector("[popover]") captures plain then o.elem.querySelector("[popover]") captures rich -> plain.textContent === "plain" && rich.textContent === "rich" && rich.classList.contains("fancy")
29
+
30
+ ## Moving the pointer away does not close a tooltip the keyboard is holding open
31
+
32
+ Hover and focus both open the tooltip, so the pointer leaving is only half the story: if the control still has focus, the tooltip is being held open by the keyboard and closing it would take away what a keyboard user is reading.
33
+
34
+ - `pointerout` hides the tooltip only when focus has also left the control
35
+ - new Subject({ tooltip: "tip", appendTo: document.body }) captures held then new Subject({ tooltip: "tip", appendTo: document.body }) captures loose then held.elem.querySelector("[popover]") captures heldTip then loose.elem.querySelector("[popover]") captures looseTip then held.elem.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, clientX: 5, clientY: 5 })) then loose.elem.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, clientX: 5, clientY: 5 })) then await new Promise(r => setTimeout(r, 760)) then held.elem.focus() then held.elem.dispatchEvent(new PointerEvent("pointerout", { bubbles: true })) then loose.elem.dispatchEvent(new PointerEvent("pointerout", { bubbles: true })) -> heldTip.style.display === "block" && looseTip.style.display === "none"
@@ -107,7 +107,10 @@ export default class Whiteboard extends Component {
107
107
 
108
108
  this.drawEvent.call(this, event);
109
109
  }).bind(this),
110
- this.options.drawThrottle || Math.max(Math.min(this.options.lineWidth + 3, 24), 6),
110
+ // `??`, not `||`: 0 is a legitimate throttle meaning "draw every move event", and `||`
111
+ // discarded it in favour of the derived rate -- so the one value a caller reaching for
112
+ // maximum fidelity would pass was the one value that silently did nothing.
113
+ this.options.drawThrottle ?? Math.max(Math.min(this.options.lineWidth + 3, 24), 6),
111
114
  );
112
115
  const removePointer = event => {
113
116
  if (!this.pointers[event.pointerId]) return;
@@ -192,9 +195,14 @@ export default class Whiteboard extends Component {
192
195
  }
193
196
 
194
197
  /**
195
- * Clears the entire canvas.
198
+ * Clears the entire canvas and discards any stroke currently being tracked.
196
199
  */
197
200
  clearCanvas() {
198
201
  this.canvas.clearRect(0, 0, this.elem.width, this.elem.height);
202
+
203
+ // Erasing the picture and forgetting the in-progress gesture are two separate jobs. Clearing
204
+ // only the pixels left every active pointer still registered, so the next pointermove resumed
205
+ // the old stroke and drew a line from wherever the pointer had been before the clear.
206
+ this.pointers = {};
199
207
  }
200
208
  }
@@ -6,30 +6,44 @@ Multi-touch drawing canvas that tracks each pointer independently. The design de
6
6
 
7
7
  ## Each pointer draws an independent line
8
8
 
9
+ Each active pointer is tracked by its own ID, which is what keeps concurrent strokes from being interleaved into one.
10
+
9
11
  - two simultaneous touches produce two independent strokes; neither interferes with the other
10
- - the whiteboard tracks each active pointer by its ID
11
- - does a second finger touching the canvas start a separate line from the first?
12
+ - new Subject({ drawThrottle: 0, appendTo: document.body }) captures w then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 10, clientY: 10 })) then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 40, clientY: 40 })) then Object.keys(w.pointers) captures both then w.elem.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 10, clientY: 10 })) -> both.length === 2 && Object.keys(w.pointers).join() === "2"
13
+
14
+ Lifting one finger ends one stroke, not the gesture. The document listeners that track movement stay attached until the last pointer leaves, and a pointer that is no longer down contributes nothing even while they are.
15
+
16
+ - a pointer that has lifted stops contributing while the others keep drawing; the move listeners are released only when the last pointer leaves
17
+ - new Array() captures drawn then new Subject({ drawThrottle: 0, appendTo: document.body }) captures w then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 10, clientY: 10 })) then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 40, clientY: 40 })) then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 10, clientY: 10 })) then w.drawLine = () => drawn.push(1) then w.elem.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 99, clientY: 99 })) then drawn.length captures fromLifted then w.elem.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 99, clientY: 99 })) then drawn.length captures fromHeld then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 99, clientY: 99 })) then w.elem.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 120, clientY: 120 })) -> fromLifted === 0 && fromHeld === 1 && drawn.length === 1
18
+ - new Subject({ drawThrottle: 0, appendTo: document.body }) captures w then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 5, clientY: 5 })) then w.drawEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 77, clientX: 9, clientY: 9 })) -> Object.keys(w.pointers).join() === "2"
12
19
 
13
20
  ## Draw throttle rate adapts to line width - no separate configuration needed
14
21
 
15
- - the throttle delay is derived from `lineWidth + 3` (clamped to a range); thicker lines are visually coarser and tolerate a longer delay
16
- - callers set `lineWidth` and the draw rate adjusts automatically; they do not configure throttle separately
22
+ The delay is derived from the line width and clamped to a usable range: thicker lines are visually coarser, so they tolerate a longer gap between sampled points without looking angular. Callers set `lineWidth` and the draw rate follows; there is no separate throttle to configure.
23
+
17
24
  - explicitly setting `drawThrottle` overrides the derived rate when the default does not fit the use case
25
+ - new Array() captures derived then new Array() captures overridden then new Subject({ lineWidth: 20, appendTo: document.body }) captures a then a.drawEvent = () => derived.push(1) then a.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 0, clientY: 0 })) then [1, 2, 3, 4, 5].forEach(i => a.elem.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: i * 5, clientY: i * 5 }))) then new Subject({ lineWidth: 20, drawThrottle: 0, appendTo: document.body }) captures b then b.drawEvent = () => overridden.push(1) then b.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 0, clientY: 0 })) then [1, 2, 3, 4, 5].forEach(i => b.elem.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: i * 5, clientY: i * 5 }))) -> derived.length === 1 && overridden.length === 5
18
26
 
19
27
  ## Completed strokes are emitted as complete lines, not as individual points
20
28
 
29
+ Callers receive whole lines, suitable for persistence or replay, rather than a stream of coordinate events they would have to reassemble.
30
+
21
31
  - when a pointer lifts, the full array of points recorded during that stroke is emitted as a line event
22
- - callers receive whole lines for persistence or replay, not a stream of coordinate events
23
- - does lifting a pointer emit all the points recorded during that stroke?
32
+ - new Array() captures seen then new Subject({ appendTo: document.body, onLine: e => seen.push(e) }) captures w then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 1, clientY: 1 })) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 5, clientY: 5 })) then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 9, clientY: 9 })) -> seen.length === 1 && Array.isArray(seen[0].detail.line)
24
33
 
25
34
  ## readOnly prevents new strokes without clearing the canvas
26
35
 
36
+ **browser:** true
37
+
38
+ Read-only mode is non-destructive: content survives the mode switch, so toggling it is safe at any time.
39
+
27
40
  - existing content remains visible; only new pointer interactions are blocked
28
- - the whiteboard is non-destructive in read-only mode; content survives the mode switch
29
- - does enabling readOnly prevent a new stroke from starting while preserving existing content?
41
+ - new Array() captures lines then new Subject({ appendTo: document.body, onLine: e => lines.push(e) }) captures w then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 10, clientY: 10 })) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 40, clientY: 40 })) then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 60, clientY: 60 })) then await new Promise(r => setTimeout(r, 80)) then [lines.length, w.elem.getContext("2d").getImageData(0, 0, w.elem.width, w.elem.height).data.reduce((n, v, i) => i % 4 === 3 && v !== 0 ? n + 1 : n, 0)] captures drawn then w.options.readOnly = true then await new Promise(r => setTimeout(r, 80)) then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 10, clientY: 10 })) then document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 40, clientY: 40 })) then document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 60, clientY: 60 })) then await new Promise(r => setTimeout(r, 80)) -> drawn[0] === 1 && drawn[1] > 0 && lines.length === drawn[0] && w.elem.getContext("2d").getImageData(0, 0, w.elem.width, w.elem.height).data.reduce((n, v, i) => i % 4 === 3 && v !== 0 ? n + 1 : n, 0) === drawn[1]
30
42
 
31
43
  ## clearCanvas wipes content without removing the element
32
44
 
33
- - `clearCanvas()` clears the 2D context and resets stroke state
34
- - the canvas element itself remains in the DOM, reusable for new drawing
35
- - does clearCanvas produce a blank canvas without removing it from the page?
45
+ The canvas element itself stays in the DOM and is immediately reusable for new drawing.
46
+
47
+ - `clearCanvas()` has two separate jobs: erase the drawn content, and reset any stroke currently being tracked -- clearing the picture does not by itself imply an in-progress gesture is also reset, so both happen explicitly
48
+ - new Array() captures cleared then new Subject({ appendTo: document.body }) captures w then w.canvas.clearRect = () => cleared.push(1) then w.clearCanvas() -> cleared.length === 1 && w.elem.isConnected
49
+ - new Subject({ appendTo: document.body }) captures w then w.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 0, clientY: 0 })) then Object.keys(w.pointers).length captures during then w.clearCanvas() -> during === 1 && Object.keys(w.pointers).length === 0
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bun
2
+
3
+ // Bridge to the lldtdd engine for the demo's "Run" button. lldtdd is a real dependency now,
4
+ // resolved from node_modules like everything else -- the sibling-checkout era is over
5
+ // (.githooks/pre-commit made the same move). Still run as a subprocess by demo/server.js so a
6
+ // crash in parsing or translation reports as JSON instead of taking the dev server down.
7
+ //
8
+ // runDocument() would be simpler but returns only counts; the demo paints a status icon onto
9
+ // every claim line, so it needs the per-case results, which means composing the pipeline the
10
+ // way lldtdd's own runner does: parse, translate, synthesize, run.
11
+ //
12
+ // Usage: bun run devTools/lldRunner.js <path-to-file.lld.md>
13
+
14
+ const [, , targetFile] = process.argv;
15
+
16
+ /**
17
+ *
18
+ */
19
+ async function main() {
20
+ if (!targetFile) {
21
+ console.log(JSON.stringify({ error: 'usage: lldRunner.js <path-to-file.lld.md>' }));
22
+ return;
23
+ }
24
+
25
+ try {
26
+ const { parseFile, translate, synthesize, runSuite } = await import('lldtdd');
27
+
28
+ const doc = await parseFile(targetFile);
29
+ const translated = await translate(doc, { execute: true });
30
+ const suite = await synthesize(translated);
31
+ const result = await runSuite(suite);
32
+
33
+ // An array, because lldMarkdown's resultsByDescription walks `for (const result of
34
+ // runResult) visit(result.cases)` -- one document still arrives as a list of one.
35
+ console.log(JSON.stringify([result]));
36
+ } catch (error) {
37
+ console.log(JSON.stringify({ error: error?.stack || String(error) }));
38
+ }
39
+ }
40
+
41
+ await main();
package/eslint.config.cjs CHANGED
@@ -32,6 +32,10 @@ module.exports = [
32
32
  },
33
33
  rules: {
34
34
  'no-console': 'warn',
35
+ // Destructure-to-omit (`const { key: _omitted, ...rest } = x`) is the idiomatic way to
36
+ // exclude a field while spreading, and the recommended config flags the discarded
37
+ // binding as unused. ignoreRestSiblings is the rule's own carve-out for exactly this.
38
+ 'no-unused-vars': ['error', { ignoreRestSiblings: true }],
35
39
  'no-nested-ternary': 'error',
36
40
  'no-var': 'error',
37
41
  'prefer-const': 'error',
package/index.d.ts CHANGED
@@ -229,7 +229,7 @@ export declare class BottomSheet extends Component {
229
229
  readonly body: Component;
230
230
  /** Slides the sheet into view and registers navigation listeners to auto-dismiss. */
231
231
  show(): void;
232
- /** Slides the sheet out of view and calls onClose if set. */
232
+ /** Slides the sheet out of view. Calls `onClose` only if the sheet was actually open — hiding an */
233
233
  hide(): void;
234
234
  }
235
235
 
@@ -255,12 +255,13 @@ export interface CalendarOptions extends ComponentOptions {
255
255
  month?: number;
256
256
  /** Initial day to display, defaults to current day */
257
257
  day?: number;
258
+ /** Day-of-week index (0-6, Sunday first) as returned by Date.getDay(); set from the selected date and read by the week view */
259
+ weekday?: number;
258
260
  /** Available view modes for toolbar */
259
261
  views?: Array<string>;
260
262
  /** Whether to display time in 24-hour format in day view (default: false) */
261
263
  display24h?: boolean;
262
264
  events?: Array<any>;
263
- weekday?: any;
264
265
  }
265
266
 
266
267
  export declare class Calendar extends Component {
@@ -364,8 +365,10 @@ export declare class Form extends Component {
364
365
  export interface IconOptions extends ComponentOptions {
365
366
  /** FontAwesome icon name (without 'fa-' prefix) */
366
367
  icon?: string;
367
- /** FontAwesome animation name (without 'fa-' prefix) */
368
+ /** FontAwesome animation name (without 'fa-' prefix), run on the whole element */
368
369
  animation?: string;
370
+ /** FontAwesome animation name (without 'fa-' prefix), run on the glyph alone */
371
+ iconAnimation?: string;
369
372
  }
370
373
 
371
374
  export declare class Icon extends Component {
@@ -622,6 +625,7 @@ export interface TableOptions extends ComponentOptions {
622
625
  sortProperty?: string;
623
626
  /** Sort direction ('asc' or 'desc') */
624
627
  sortDirection?: 'asc' | 'desc';
628
+ /** Caller-owned selection state. The table stores it and re-renders * when it is reassigned, but never interprets it; `dataColumn` functions read it back off * `table.options.selection` to render per-row state. Mutating a property of it deliberately does * not re-render, so toggling one row's checkbox does not rebuild the table under the pointer */
625
629
  selection?: any;
626
630
  }
627
631
 
@@ -687,7 +691,7 @@ export declare class Whiteboard extends Component {
687
691
  constructor(options?: WhiteboardOptions, ...children: Array<Elem | HTMLElement | string>);
688
692
  /** Draws a line on the canvas with specified properties. */
689
693
  drawLine(options: Record<string, any>): void;
690
- /** Clears the entire canvas. */
694
+ /** Clears the entire canvas and discards any stroke currently being tracked. */
691
695
  clearCanvas(): void;
692
696
  }
693
697
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanilla-bean/components",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "type": "module",
5
5
  "module": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -32,7 +32,7 @@
32
32
  "/utils/**/*",
33
33
  "index.js",
34
34
  "index.d.ts",
35
- "./FontWithASyntaxHighlighter-Regular.woff2",
35
+ "FontWithASyntaxHighlighter-Regular.woff2",
36
36
  "!/components/**/demo.js",
37
37
  "!/components/**/*.test.js",
38
38
  "!/utils/**/*.test.js",
@@ -73,6 +73,8 @@
73
73
  "create:component": "bun run devTools/createComponent.js",
74
74
  "lint": "bun --bun eslint",
75
75
  "lint:fix": "bun --bun eslint --fix",
76
+ "lld": "lldtdd run",
77
+ "lld:lint": "lldtdd lint",
76
78
  "format": "BROWSERSLIST_IGNORE_OLD_DATA=1 bun run lint:fix && bun --bun prettier --write . --log-level warn",
77
79
  "test": "bun test",
78
80
  "test:watch": "bun test --watch",
@@ -114,6 +116,7 @@
114
116
  "eslint-plugin-write-good-comments": "^0.2.0",
115
117
  "globals": "17",
116
118
  "happy-dom": "20",
119
+ "lldtdd": "^0.1.0",
117
120
  "marked": "18",
118
121
  "moresketchy": "wbern/MoreSketchy.js",
119
122
  "prettier": "^3.9.5"
@@ -6,6 +6,7 @@ module.exports = {
6
6
  templates: true,
7
7
  lang: 'en_US',
8
8
  skipWords: [
9
+ 'bezier',
9
10
  'ecma',
10
11
  'compat',
11
12
  'eslintrc',
package/theme/button.js CHANGED
@@ -29,6 +29,15 @@ export default ({ colors, fonts }) => `
29
29
  padding: 0 6px 0 3px;
30
30
  }
31
31
 
32
+ /*
33
+ * An animated glyph turns about the center of its own box, and the padding above is heavier on
34
+ * one side, which would swing it off-center. Evening it out keeps the same total width, so
35
+ * starting an animation never shifts the label beside it.
36
+ */
37
+ &[class*='icon-animation-']:before {
38
+ padding: 0 4.5px;
39
+ }
40
+
32
41
  &:after {
33
42
  content: '';
34
43
  position: absolute;
package/theme/page.js CHANGED
@@ -211,4 +211,66 @@ export default theme => `
211
211
  ${fonts.fontAwesomeSolid}
212
212
  content: var(--fa);
213
213
  }
214
+
215
+ /*
216
+ * Icon's iconAnimation option. FontAwesome's own animation classes animate the element that
217
+ * carries them, which is what you want on a bare Icon and not what you want on anything that
218
+ * also renders a label — Button and Link extend Icon, so an fa animation there spins the
219
+ * control, background and text included. These aim the same animations at the :before that
220
+ * draws the glyph, reusing FontAwesome's keyframes and honoring the same --fa-animation-*
221
+ * overrides, so a caller can animate the glyph and the element independently.
222
+ */
223
+ [class*='icon-animation-']:before {
224
+ animation-delay: var(--fa-animation-delay, 0s);
225
+ animation-direction: var(--fa-animation-direction, normal);
226
+ animation-iteration-count: var(--fa-animation-iteration-count, infinite);
227
+ }
228
+
229
+ .icon-animation-spin:before {
230
+ animation-name: fa-spin;
231
+ animation-duration: var(--fa-animation-duration, 2s);
232
+ animation-timing-function: var(--fa-animation-timing, linear);
233
+ }
234
+
235
+ .icon-animation-spin-pulse:before {
236
+ animation-name: fa-spin;
237
+ animation-duration: var(--fa-animation-duration, 1s);
238
+ animation-timing-function: var(--fa-animation-timing, steps(8));
239
+ }
240
+
241
+ .icon-animation-beat:before {
242
+ animation-name: fa-beat;
243
+ animation-duration: var(--fa-animation-duration, 1s);
244
+ animation-timing-function: var(--fa-animation-timing, ease-in-out);
245
+ }
246
+
247
+ .icon-animation-fade:before {
248
+ animation-name: fa-fade;
249
+ animation-duration: var(--fa-animation-duration, 1s);
250
+ animation-timing-function: var(--fa-animation-timing, ease-in-out);
251
+ }
252
+
253
+ .icon-animation-beat-fade:before {
254
+ animation-name: fa-beat-fade;
255
+ animation-duration: var(--fa-animation-duration, 1s);
256
+ animation-timing-function: var(--fa-animation-timing, ease-in-out);
257
+ }
258
+
259
+ .icon-animation-bounce:before {
260
+ animation-name: fa-bounce;
261
+ animation-duration: var(--fa-animation-duration, 1s);
262
+ animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
263
+ }
264
+
265
+ .icon-animation-flip:before {
266
+ animation-name: fa-flip;
267
+ animation-duration: var(--fa-animation-duration, 1.5s);
268
+ animation-timing-function: var(--fa-animation-timing, ease-in-out);
269
+ }
270
+
271
+ .icon-animation-shake:before {
272
+ animation-name: fa-shake;
273
+ animation-duration: var(--fa-animation-duration, 0.75s);
274
+ animation-timing-function: var(--fa-animation-timing, ease-in-out);
275
+ }
214
276
  `;
@@ -1,88 +0,0 @@
1
- import Component from './Component.js';
2
-
3
- export const __lld_api = `
4
- destructiveRender() -> boolean | renders a component twice; returns true if child count matches (1) - second render cleared the first
5
- optionReactionFires() -> number | assigns to option after render; returns how many times _setOption was called (1 = reactive)
6
- buildBeforeOptions() -> boolean | verifies build() DOM structure exists when _setOption runs (true = ordering is correct)
7
- priorityRunsFirst() -> boolean | textContent (priority option) runs before style (non-priority); returns true if correct order
8
- replaceCleanupRunsOnce() -> number | replaceCleanup called 3x with same key; returns count of fns that ran at processCleanup time (1 = no accumulation)
9
- `;
10
-
11
- export const destructiveRender = () => {
12
- class TestComp extends Component {
13
- build() {
14
- this.elem.append(document.createElement('span'));
15
- }
16
- }
17
- const comp = new TestComp({ autoRender: false });
18
- comp.render();
19
- const afterFirst = comp.elem?.children?.length ?? 0;
20
- comp.render();
21
- const afterSecond = comp.elem?.children?.length ?? 0;
22
- return afterFirst === 1 && afterSecond === 1;
23
- };
24
-
25
- export const optionReactionFires = () => {
26
- let reactions = 0;
27
- class TestComp extends Component {
28
- _setOption(key, value) {
29
- if (key === 'textContent' && this.rendered) reactions++;
30
- super._setOption(key, value);
31
- }
32
- }
33
- const comp = new TestComp({ autoRender: false });
34
- comp.render();
35
- const baseline = reactions;
36
- comp.options.textContent = 'hello';
37
- return reactions - baseline;
38
- };
39
-
40
- export const buildBeforeOptions = () => {
41
- let buildRanBeforeOption = false;
42
- let buildRan = false;
43
- class TestComp extends Component {
44
- build() {
45
- buildRan = true;
46
- this.elem.append(document.createElement('span'));
47
- }
48
- _setOption(key, value) {
49
- if (key === 'textContent') buildRanBeforeOption = buildRan;
50
- super._setOption(key, value);
51
- }
52
- }
53
- const comp = new TestComp({ textContent: 'hello', autoRender: false });
54
- comp.render();
55
- return buildRanBeforeOption;
56
- };
57
-
58
- export const priorityRunsFirst = () => {
59
- const order = [];
60
- class TestComp extends Component {
61
- build() {}
62
- _setOption(key, value) {
63
- if (key === 'textContent' || key === 'style') order.push(key);
64
- super._setOption(key, value);
65
- }
66
- }
67
- // textContent is a priority option; style is not
68
- const comp = new TestComp({ textContent: 'hello', style: { color: 'red' }, autoRender: false });
69
- comp.render();
70
- return order[0] === 'textContent';
71
- };
72
-
73
- export const replaceCleanupRunsOnce = () => {
74
- const comp = new Component({ autoRender: false });
75
- let count = 0;
76
- comp.replaceCleanup('test', () => {
77
- count++;
78
- });
79
- comp.replaceCleanup('test', () => {
80
- count++;
81
- }); // runs previous = 1
82
- comp.replaceCleanup('test', () => {
83
- count++;
84
- }); // runs previous = 2, stores latest
85
- const countBeforeCleanup = count;
86
- comp.processCleanup(); // runs only the latest = 3
87
- return count - countBeforeCleanup; // 1 - only latest ran at cleanup time
88
- };