@vanilla-bean/components 2.0.0 → 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 +5 -1
- package/index.d.ts +8 -4
- package/package.json +54 -7
- package/spellcheck.config.cjs +2 -0
- package/theme/button.js +9 -0
- package/theme/page.js +62 -0
- 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
|
|
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();
|
|
Binary file
|
|
@@ -104,9 +104,15 @@ class BottomSheet extends styled(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
* Slides the sheet out of view
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
18
|
-
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
21
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
10
|
-
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
11
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
package/components/Icon/Icon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
-
|
|
16
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
11
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
23
|
-
-
|
|
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
|
-
-
|
|
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
|