@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.
- package/Component/observeElementConnection.js +3 -3
- package/Component/observeElementConnection.test.js +15 -0
- package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
- package/components/BottomSheet/BottomSheet.js +8 -2
- package/components/BottomSheet/BottomSheet.lld.md +22 -10
- package/components/Button/Button.lld.md +11 -3
- package/components/Calendar/Calendar.js +27 -10
- package/components/Calendar/Calendar.lld.md +42 -6
- package/components/Calendar/Toolbar.js +16 -0
- package/components/Code/Code.lld.md +3 -3
- package/components/ColorPicker/ColorPicker.lld.md +27 -4
- package/components/Dialog/Dialog.lld.md +14 -0
- package/components/Form/Form.lld.md +33 -3
- package/components/Icon/Icon.js +24 -3
- package/components/Icon/Icon.lld.md +21 -3
- package/components/Input/Input.lld.md +30 -6
- package/components/Keyboard/Keyboard.js +1 -1
- package/components/Keyboard/Keyboard.lld.md +25 -4
- package/components/Label/Label.js +10 -2
- package/components/Label/Label.lld.md +31 -1
- package/components/Link/Link.js +18 -1
- package/components/Link/Link.lld.md +8 -1
- package/components/List/List.lld.md +9 -2
- package/components/Menu/Menu.js +10 -1
- package/components/Menu/Menu.lld.md +23 -2
- package/components/Notify/Notify.lld.md +16 -4
- package/components/Page/Page.lld.md +15 -4
- package/components/Popover/Popover.lld.md +5 -1
- package/components/RadioButton/RadioButton.js +6 -1
- package/components/RadioButton/RadioButton.lld.md +14 -1
- package/components/Router/Router.js +6 -14
- package/components/Router/Router.lld.md +16 -11
- package/components/Select/Select.js +22 -3
- package/components/Select/Select.lld.md +26 -2
- package/components/Table/Table.js +19 -4
- package/components/Table/Table.lld.md +19 -5
- package/components/TagList/Tag.js +14 -0
- package/components/TagList/TagList.js +0 -18
- package/components/TagList/TagList.lld.md +12 -7
- package/components/Tooltip/Tooltip.lld.md +12 -4
- package/components/TooltipWrapper/TooltipWrapper.lld.md +14 -0
- package/components/Whiteboard/Whiteboard.js +10 -2
- package/components/Whiteboard/Whiteboard.lld.md +25 -11
- package/devTools/lldRunner.js +41 -0
- package/eslint.config.cjs +4 -0
- package/index.d.ts +8 -4
- package/package.json +5 -2
- package/spellcheck.config.cjs +1 -0
- package/theme/button.js +9 -0
- package/theme/page.js +62 -0
- 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
|
-
-
|
|
11
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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"
|
package/spellcheck.config.cjs
CHANGED
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
|
-
};
|