@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
|
@@ -7,15 +7,36 @@ On-screen keyboard where layout switching rebuilds the key DOM rather than showi
|
|
|
7
7
|
## Layout switch produces only keys from the new layout - no residual keys remain
|
|
8
8
|
|
|
9
9
|
- changing `layout` removes all existing keys and builds the new set from scratch; a key that exists in layout A but not layout B is definitively absent after the switch
|
|
10
|
-
-
|
|
11
|
-
-
|
|
10
|
+
- new Subject({ layout: "simple", appendTo: document.body }) captures k then Array.from(k.elem.querySelectorAll("button")).map(b => b.textContent) captures before then k.options.layout = "number" then Array.from(k.elem.querySelectorAll("button")).map(b => b.textContent) captures after -> before.includes("q") && after.includes("q") === false
|
|
11
|
+
- new Subject({ layout: "simple", appendTo: document.body }) captures k then Array.from(k.elem.querySelectorAll("button")).map(b => b.textContent) captures before then k.options.layout = "number" then Array.from(k.elem.querySelectorAll("button")).map(b => b.textContent) captures after -> before.includes("1") === false && after.includes("1")
|
|
12
12
|
|
|
13
13
|
## Key events carry the key definition alongside the key name
|
|
14
14
|
|
|
15
15
|
- `keyDown`, `keyUp`, and `keyPress` emit with both the key name and its configuration object, so handlers can respond to semantic meaning rather than just the character pressed
|
|
16
|
-
-
|
|
16
|
+
- new Array() captures seen then new Subject({ layout: "simple", appendTo: document.body, onKeyPress: e => seen.push(e.detail) }) captures k then Array.from(k.elem.querySelectorAll("button")).find(b => b.textContent === "ABC") captures key then key.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then key.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) -> seen.length === 1 && seen[0].key === "simple" && seen[0].keyDefinition.text === "ABC"
|
|
17
|
+
|
|
18
|
+
## A key is named by its layout entry, not by what it sends
|
|
19
|
+
|
|
20
|
+
A definition's `key` is what a press sends; the layout entry is the key's name. Styling and labels hang off the name, so two entries that send the same key stay distinct buttons, and a definition that only changes what is sent keeps its own label and class.
|
|
21
|
+
|
|
22
|
+
- a definition's `key` does not rename the button: its class and fallback label come from the layout entry
|
|
23
|
+
- new Subject({ layout: "media", keyDefinitions: { volUp: { key: "audio_vol_up", text: "" }, "!": { mod: "shift", key: "1" } }, layouts: { media: [["volUp", "!"]] }, appendTo: document.body }) captures k then Array.from(k.elem.querySelectorAll("button")) captures keys -> keys[0].classList.contains("volUp") && !keys[0].classList.contains("audio_vol_up") && keys[1].textContent === "!"
|
|
24
|
+
- a press on a renamed key still emits the definition's `key`, so the handler receives what the definition sends
|
|
25
|
+
- new Array() captures seen then new Subject({ layout: "media", keyDefinitions: { volUp: { key: "audio_vol_up", text: "" } }, layouts: { media: [["volUp"]] }, appendTo: document.body, onKeyPress: e => seen.push(e.detail) }) captures k then k.elem.querySelector("button.volUp") captures key then key.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then key.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) -> seen.length === 1 && seen[0].key === "audio_vol_up"
|
|
17
26
|
|
|
18
27
|
## Regex-named keys match families of keys to one definition
|
|
19
28
|
|
|
20
29
|
- a key definition whose name is a regex pattern matches all physical keys that satisfy it; modifier handling and key families are expressed as patterns, not enumerated one by one
|
|
21
|
-
-
|
|
30
|
+
- new Subject({ layout: "number", keyDefinitions: { "^[0-9]$": { class: "digit" } }, appendTo: document.body }) captures k then Array.from(k.elem.querySelectorAll("button")) captures keys then keys.filter(b => b.className.includes("digit")).map(b => b.textContent) captures digits -> digits.length === 10 && digits.includes("0") && digits.includes("9") && !keys.some(b => b.textContent === "backspace" && b.className.includes("digit"))
|
|
31
|
+
|
|
32
|
+
A name only counts as a pattern when it is anchored at both ends. Without that rule an ordinary name is a substring match against every key, so a definition called `a` would silently claim every key containing an `a`.
|
|
33
|
+
|
|
34
|
+
- an unanchored name matches one key by name; only a name anchored at both ends is treated as a pattern
|
|
35
|
+
- new Subject({ layout: "number", keyDefinitions: { ".": { class: "dot" } }, appendTo: document.body }) captures k then Array.from(k.elem.querySelectorAll("button")) captures keys then keys.filter(b => b.className.includes("dot")) captures dotted -> keys.length > 5 && dotted.length === 1 && dotted[0].textContent === "."
|
|
36
|
+
|
|
37
|
+
## Subscribing to a key event returns the means to stop
|
|
38
|
+
|
|
39
|
+
Callers wire key handlers into components that come and go, so a subscription that cannot be undone is a leak. The subscribe helpers hand back an unsubscribe rather than requiring the caller to keep the original function around to pass to a removal call.
|
|
40
|
+
|
|
41
|
+
- `onKeyDown`, `onKeyUp` and `onKeyPress` return a function that ends the subscription; after calling it, further presses are not delivered
|
|
42
|
+
- new Array() captures seen then new Subject({ layout: "simple", appendTo: document.body }) captures k then k.onKeyDown(() => seen.push("down")) captures offDown then k.onKeyUp(() => seen.push("up")) captures offUp then k.onKeyPress(() => seen.push("press")) captures offPress then Array.from(k.elem.querySelectorAll("button")).find(b => b.textContent === "q") captures key then key.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then key.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) then seen.slice() captures whileSubscribed then offDown() then offUp() then offPress() then key.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then key.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) -> whileSubscribed.length === 3 && seen.length === 3
|
|
@@ -171,8 +171,16 @@ class Label extends StyledLabel {
|
|
|
171
171
|
set(value) {
|
|
172
172
|
let forId = typeof value === 'string' ? value : value?.id || value?.elem?.id;
|
|
173
173
|
|
|
174
|
-
if (!forId
|
|
175
|
-
|
|
174
|
+
if (!forId) {
|
|
175
|
+
// Two shapes reach here: a component, which carries `elem` and `uniqueId`, and a raw
|
|
176
|
+
// element, which carries a `_component` backreference. The guard tested for the element
|
|
177
|
+
// shape while the body used the component shape, so neither worked -- a component
|
|
178
|
+
// produced an empty `for` and an element threw on `value.elem.id`. A label that looks
|
|
179
|
+
// associated but is not is worse than one that never claimed to be.
|
|
180
|
+
const component = value?.uniqueId ? value : value?._component;
|
|
181
|
+
const elem = value?.elem ?? (value?._component ? value : undefined);
|
|
182
|
+
|
|
183
|
+
if (component && elem) forId = elem.id = component.uniqueId;
|
|
176
184
|
}
|
|
177
185
|
|
|
178
186
|
if (this._labelText) this._labelText.elem.htmlFor = forId ?? '';
|
|
@@ -6,8 +6,10 @@ Labeling wrapper with five structural variants. The key decision is that `varian
|
|
|
6
6
|
|
|
7
7
|
## Each variant applies a distinct class that drives its CSS behavior
|
|
8
8
|
|
|
9
|
+
**browser:** true
|
|
10
|
+
|
|
9
11
|
- 'collapsible', 'overlay', 'inline', 'inline-after', and 'simple' each apply their own class to the component element; swapping `variant` changes the class and the structural CSS rules that attach to it
|
|
10
|
-
-
|
|
12
|
+
- new Subject({ variant: "collapsible", label: "Section", appendTo: document.body }, Object.assign(document.createElement("div"), { id: "lld-content", textContent: "wrapped" })) captures l then l.elem.querySelector("label") captures lab then l.elem.querySelector("#lld-content") captures content then getComputedStyle(content).display captures atFirst then lab.click() then await new Promise(r => setTimeout(r, 80)) then getComputedStyle(content).display captures afterClick then lab.click() then await new Promise(r => setTimeout(r, 80)) -> getComputedStyle(lab).cursor === "pointer" && atFirst !== "none" && afterClick === "none" && getComputedStyle(content).display === atFirst
|
|
11
13
|
|
|
12
14
|
## Collapsed state is externally controllable, not just toggle-driven
|
|
13
15
|
|
|
@@ -17,4 +19,32 @@ Labeling wrapper with five structural variants. The key decision is that `varian
|
|
|
17
19
|
|
|
18
20
|
## Overlay label visibility is driven by CSS pseudo-class, not JavaScript
|
|
19
21
|
|
|
22
|
+
**browser:** true
|
|
23
|
+
|
|
20
24
|
- the overlay variant's label visibility responds to whether the input has content via the `:placeholder-shown` pseudo-class; the component adds the structural class and the CSS handles the rest
|
|
25
|
+
- new Subject({ variant: "overlay", label: "Name", appendTo: document.body }, Object.assign(document.createElement("input"), { placeholder: "type here" })) captures l then await new Promise(r => setTimeout(r, 700)) then l.elem.querySelector("label") captures lab then getComputedStyle(lab).transform captures whileEmpty then l.elem.querySelector("input").value = "filled" then await new Promise(r => setTimeout(r, 700)) -> whileEmpty !== "matrix(1, 0, 0, 1, 0, 0)" && getComputedStyle(lab).transform === "matrix(1, 0, 0, 1, 0, 0)"
|
|
26
|
+
|
|
27
|
+
## The variant decides which side the label text sits on, and moving it is a move
|
|
28
|
+
|
|
29
|
+
`inline-after` is the only variant that puts the text after the control, so switching variants has to relocate the existing text node rather than render a second one. Reassigning a variant is a reposition, not a rebuild.
|
|
30
|
+
|
|
31
|
+
- the label text precedes the control except under `inline-after`, and changing variant moves the same text rather than adding another
|
|
32
|
+
- new Subject({ label: "Name", variant: "inline", appendTo: document.body, append: document.createElement("input") }) captures l then Array.from(l.elem.children).indexOf(l._labelText.elem) captures asInline then l.options.variant = "inline-after" then Array.from(l.elem.children).indexOf(l._labelText.elem) captures asAfter then l.options.variant = "inline" -> asInline === 0 && asAfter === 1 && Array.from(l.elem.children).indexOf(l._labelText.elem) === 0 && l.elem.children.length === 2
|
|
33
|
+
|
|
34
|
+
## A collapsible label reports its own expanded state
|
|
35
|
+
|
|
36
|
+
The label text is what a reader activates to open and close the section, so it is the element that has to carry `aria-expanded` -- and it has to stay in step with the `collapsed` option rather than being set once at construction.
|
|
37
|
+
|
|
38
|
+
- a collapsible label starts expanded and follows the `collapsed` option
|
|
39
|
+
- new Subject({ label: "Sect", variant: "collapsible", appendTo: document.body }) captures l then l._labelText.elem.getAttribute("aria-expanded") captures atStart then l.options.collapsed = true -> atStart === "true" && l._labelText.elem.getAttribute("aria-expanded") === "false" && l.hasClass("collapsed")
|
|
40
|
+
- becoming collapsible is what adds the state; a label that is not collapsible does not claim one
|
|
41
|
+
- new Subject({ label: "Sect", variant: "inline", appendTo: document.body }) captures l then l._labelText.elem.getAttribute("aria-expanded") captures asInline then l.options.variant = "collapsible" -> asInline === null && l._labelText.elem.getAttribute("aria-expanded") === "true"
|
|
42
|
+
|
|
43
|
+
## The label is text or a full set of options, and `for` takes whatever identifies the control
|
|
44
|
+
|
|
45
|
+
A label is usually a string, and stays that short. When it needs more it carries the label element's own options instead. `for` is the same idea applied to the target: a string id when the caller has one, and otherwise the control itself -- as a component or as an element -- with the label assigning an id if the control has none, because an association that silently fails is indistinguishable from a label that was never wired.
|
|
46
|
+
|
|
47
|
+
- a string `label` becomes the text; an object is used as the label element's options
|
|
48
|
+
- new Subject({ label: "Str", appendTo: document.body }) captures s then new Subject({ label: { textContent: "Obj", addClass: "fancy" }, appendTo: document.body }) captures o -> s._labelText.elem.textContent === "Str" && o._labelText.elem.textContent === "Obj" && o._labelText.elem.classList.contains("fancy")
|
|
49
|
+
- `for` accepts an id string, a component, or an element, and gives an unidentified control an id so the association actually holds
|
|
50
|
+
- new Subject({ label: "L", for: "explicit", appendTo: document.body }) captures byString then new Subject({ tag: "input" }) captures control then document.body.append(control.elem) then new Subject({ label: "L", for: control, appendTo: document.body }) captures byComponent -> byString._labelText.elem.htmlFor === "explicit" && control.elem.id.length > 0 && byComponent._labelText.elem.htmlFor === control.elem.id
|
package/components/Link/Link.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { TooltipWrapper } from '../TooltipWrapper';
|
|
2
2
|
|
|
3
|
+
/** The decoration every Link tooltip carries, merged onto whatever the caller passed. */
|
|
4
|
+
const linkTooltip = { icon: 'link', style: { fontSize: '12px' } };
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Link component with tooltip support and display variants.
|
|
5
8
|
*
|
|
@@ -27,8 +30,22 @@ class Link extends TooltipWrapper {
|
|
|
27
30
|
},
|
|
28
31
|
},
|
|
29
32
|
tooltip: {
|
|
33
|
+
// One source for the decoration, read by both the default and the merge below; it used to
|
|
34
|
+
// be spelled out twice, once here and once inline in `set()`.
|
|
35
|
+
//
|
|
36
|
+
// The `default` has to stay. Replacing its *contents* with null changes no rendered
|
|
37
|
+
// tooltip -- `set()` supplies the decoration either way -- but removing the descriptor
|
|
38
|
+
// entirely means `set()` never runs for a Link that was given no tooltip, and the link
|
|
39
|
+
// decoration disappears. What the default provides is the presence of a value to set,
|
|
40
|
+
// not the value itself.
|
|
30
41
|
get default() {
|
|
31
|
-
return
|
|
42
|
+
return linkTooltip;
|
|
43
|
+
},
|
|
44
|
+
// Merge the decoration onto the caller's value so a plain string tooltip still gets the
|
|
45
|
+
// link icon, not just the object form. `next` continues the schema chain to
|
|
46
|
+
// TooltipWrapper's own tooltip set(), which expects an object or a string.
|
|
47
|
+
set(value, next) {
|
|
48
|
+
next(typeof value === 'object' ? { ...linkTooltip, ...value } : { ...linkTooltip, textContent: value });
|
|
32
49
|
},
|
|
33
50
|
},
|
|
34
51
|
};
|
|
@@ -12,4 +12,11 @@ Anchor element with optional button styling. The decision: `variant: 'button'` c
|
|
|
12
12
|
## Tooltip includes a link icon automatically
|
|
13
13
|
|
|
14
14
|
- when a tooltip string is provided, the tooltip is configured with a link icon alongside the text
|
|
15
|
-
-
|
|
15
|
+
- new Subject({ href: "#x", tooltip: "hello", textContent: "go" }) captures l -> l._tooltip.options.icon === "link" && l._tooltip.options.textContent === "hello"
|
|
16
|
+
|
|
17
|
+
## The link icon comes with the tooltip, however the tooltip was written
|
|
18
|
+
|
|
19
|
+
A link's tooltip is what tells a reader it leaves the page, so the icon is part of the default rather than something each call site remembers. A caller passing a plain string still gets it -- the default is merged onto their value, not replaced by it.
|
|
20
|
+
|
|
21
|
+
- the tooltip carries the link icon whether the caller passed a string or nothing at all
|
|
22
|
+
- new Subject({ href: "https://example.com", textContent: "o", tooltip: "text tip", appendTo: document.body }) captures withString then new Subject({ href: "https://example.com", textContent: "o", appendTo: document.body }) captures byDefault -> withString.elem.querySelector("[popover]").style.fontSize === "12px" && byDefault.elem.querySelector("[popover]").style.fontSize === "12px" && withString.elem.querySelector("[popover]").textContent === "text tip"
|
|
@@ -8,12 +8,19 @@ Flexible list that meets items where they are. The core design decision: items c
|
|
|
8
8
|
|
|
9
9
|
- a single `items` array can contain mixed formats without error
|
|
10
10
|
- does a List render a mix of string and object items without failing?
|
|
11
|
+
- new Subject({ items: undefined, appendTo: document.body }) captures missing then new Subject({ items: null, appendTo: document.body }) captures empty -> missing.elem.querySelectorAll("li").length === 0 && empty.elem.querySelectorAll("li").length === 0
|
|
12
|
+
- new Array() captures received then new Subject({ items: [{ textContent: "hi", listItemOptions: { addClass: "wrap" }, ListItemComponent: class { constructor(o) { received.push(Object.keys(o).join(",")); this.elem = document.createElement("b"); this.elem.textContent = "C"; } } }], appendTo: document.body }) captures l then l.elem.querySelector("li") captures li -> li.classList.contains("wrap") && received.length === 1 && !received[0].includes("listItemOptions")
|
|
11
13
|
|
|
12
14
|
## Per-item component overrides compose cleanly with the global default
|
|
13
15
|
|
|
14
16
|
- the component set globally on the list applies to all items; a per-item `ListItemComponent` overrides for that item only
|
|
15
|
-
-
|
|
17
|
+
- new Subject({ items: ["plain", { textContent: "x", ListItemComponent: class { constructor(o) { this.elem = document.createElement("b"); this.elem.textContent = "CUSTOM"; } } }] }) captures l then document.body.append(l.elem) then Array.from(l.elem.querySelectorAll("li")) captures items -> items.length === 2 && items[0].textContent === "plain" && items[1].querySelector("b").textContent === "CUSTOM"
|
|
16
18
|
|
|
17
19
|
## noStyle opts out of default list chrome for embedding contexts
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
**browser:** true
|
|
22
|
+
|
|
23
|
+
The chrome being removed is CSS, so the check has to ask a real engine what it computed; a class name on the element proves only that the class was set.
|
|
24
|
+
|
|
25
|
+
- `noStyle: true` removes the default list chrome (bullets, padding and line-height), leaving spacing to the embedding layout
|
|
26
|
+
- new Subject({ items: ["a"], noStyle: true, appendTo: document.body }) captures plain then new Subject({ items: ["a"], appendTo: document.body }) captures styled then getComputedStyle(plain.elem) captures ps then getComputedStyle(styled.elem) captures ss -> ps.listStyleType === "none" && ps.paddingLeft === "0px" && ss.listStyleType !== "none"
|
package/components/Menu/Menu.js
CHANGED
|
@@ -61,7 +61,16 @@ export default class Menu extends StyledList {
|
|
|
61
61
|
this.on({
|
|
62
62
|
targetEvent: 'pointerdown',
|
|
63
63
|
id: 'menuSelect',
|
|
64
|
-
callback: event =>
|
|
64
|
+
callback: event => {
|
|
65
|
+
// Selecting requires an item, the same way the keyboard path below requires a focused
|
|
66
|
+
// one. Emitting for any pointerdown on the menu meant clicking the padding, the gap
|
|
67
|
+
// between items, or the border fired `select` with nothing selected, leaving every
|
|
68
|
+
// consumer to work out from the event whether an item had actually been hit.
|
|
69
|
+
const item = event.target?.closest?.('li');
|
|
70
|
+
if (!item || !this.elem.contains(item)) return;
|
|
71
|
+
|
|
72
|
+
this.emit('select', event);
|
|
73
|
+
},
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
this.on({
|
|
@@ -2,14 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
> ./Menu.js
|
|
4
4
|
|
|
5
|
-
Styled list whose items emit a registered `select` CustomEvent, with the activation event in `detail`. The design decision:
|
|
5
|
+
Styled list whose items emit a registered `select` CustomEvent, with the activation event in `detail`. The design decision: pointer and keyboard activation both resolve to the same `select` event -- the same principle Button applies to `onPointerPress` -- so keyboard and pointer users get the same experience without separate handlers. `onSelect` is the option-form listener for the event.
|
|
6
6
|
|
|
7
7
|
## Selecting a menu item works the same way regardless of input method
|
|
8
8
|
|
|
9
|
-
- clicking, touching, Space/Enter, and screen reader activation all emit `select`; one
|
|
9
|
+
- clicking, touching, Space/Enter, and screen reader activation all emit `select`; one event, no separate keyboard path
|
|
10
10
|
- does clicking a menu item invoke onSelect?
|
|
11
|
+
- does pressing Enter on a focused menu item invoke onSelect?
|
|
11
12
|
|
|
12
13
|
## Menu renders any item format that List supports
|
|
13
14
|
|
|
14
15
|
- strings, objects with labels, and component instances all work as menu items
|
|
15
16
|
- does a Menu with mixed string and object items render without error?
|
|
17
|
+
|
|
18
|
+
## Exactly one item is in the tab order at a time
|
|
19
|
+
|
|
20
|
+
A menu is one stop for the keyboard, not one stop per item: tabbing past a ten-item menu should take one press, and arrow keys move within it. That means a roving tabindex -- the first item is focusable, the rest are reachable only from inside.
|
|
21
|
+
|
|
22
|
+
- the first item carries `tabindex=0` and every other item `-1`, so the menu is a single tab stop
|
|
23
|
+
- new Subject({ items: [{ textContent: "a" }, { textContent: "b" }, { textContent: "c" }], appendTo: document.body }) captures m then Array.from(m.elem.querySelectorAll("li")).map(li => li.getAttribute("tabindex")) captures tabs -> tabs.join() === "0,-1,-1"
|
|
24
|
+
- an items list that is absent renders an empty menu rather than throwing
|
|
25
|
+
- new Subject({ items: undefined, appendTo: document.body }) captures missing then new Subject({ items: null, appendTo: document.body }) captures empty -> missing.elem.querySelectorAll("li").length === 0 && empty.elem.querySelectorAll("li").length === 0
|
|
26
|
+
- activation on a node outside the menu selects nothing, so a menu never claims an event that was not its own
|
|
27
|
+
- new Array() captures fired then new Subject({ items: [{ textContent: "a" }], onSelect: () => fired.push(1), appendTo: document.body }) captures m then document.createElement("li") captures outsider then document.body.append(outsider) then outsider.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then outsider.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) -> fired.length === 0
|
|
28
|
+
- pressing the menu's own padding selects nothing either; only a press that lands on an item counts
|
|
29
|
+
- new Array() captures fired then new Subject({ items: [{ textContent: "a" }], onSelect: () => fired.push(1), appendTo: document.body }) captures m then m.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then m.elem.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) then fired.length captures afterRootPress then m.elem.querySelector("li") captures item then item.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then item.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })) -> afterRootPress === 0 && fired.length === 1
|
|
30
|
+
|
|
31
|
+
## Arrow keys move focus within the menu and wrap at both ends
|
|
32
|
+
|
|
33
|
+
The roving tabindex makes the menu one tab stop; arrow keys are what move inside it. Wrapping means a reader holding the key never lands on nothing, and it is what makes the first and last items reachable from each other without a Home/End convention.
|
|
34
|
+
|
|
35
|
+
- ArrowDown advances and wraps past the last item; ArrowUp retreats and wraps past the first
|
|
36
|
+
- new Subject({ items: [{ textContent: "a" }, { textContent: "b" }, { textContent: "c" }], appendTo: document.body }) captures m then Array.from(m.elem.querySelectorAll("li")) captures lis then lis[0].focus() then new Array() captures seq then seq.push(lis.indexOf(document.activeElement)) then ["ArrowDown", "ArrowDown", "ArrowDown", "ArrowUp"].forEach(key => { m.elem.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); seq.push(lis.indexOf(document.activeElement)); }) -> seq.join() === "0,1,2,0,2"
|
|
@@ -7,14 +7,26 @@ Transient notification that self-destructs. The design choice is that click-to-d
|
|
|
7
7
|
## Type communicates intent; the component selects the appropriate icon
|
|
8
8
|
|
|
9
9
|
- callers pass `type: 'error'` and the component picks the matching icon; a custom `icon` option overrides this only when the standard mapping doesn't fit
|
|
10
|
-
-
|
|
10
|
+
- new Subject({ type: "success", textContent: "x" }) captures ok then new Subject({ type: "error", textContent: "x" }) captures bad then [ok, bad].map(n => (n.elem.className.match(/fa-[\w-]+/g) || []).join(" ")) captures icons -> icons[0].includes("fa-") && icons[1].includes("fa-") && icons[0] !== icons[1]
|
|
11
11
|
|
|
12
12
|
## Clicking anywhere on the notification dismisses it - the whole surface is the target
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
Notifications are temporary and should be easy to clear, so there is no separate close button to aim at.
|
|
15
|
+
|
|
16
|
+
- a click anywhere on the notification dismisses it, its body included, rather than only on a dedicated control
|
|
17
|
+
- new Subject({ textContent: "hi", appendTo: document.body }) captures n then n.elem.isConnected captures shown then n.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) -> shown === true && n.elem.isConnected === false
|
|
16
18
|
|
|
17
19
|
## Manual dismiss cancels a pending timeout - no duplicate destroy fires
|
|
18
20
|
|
|
19
21
|
- when a `timeout` is set and the user dismisses manually before it fires, the timer is cancelled so a second destroy call after the component is already gone cannot happen
|
|
20
|
-
-
|
|
22
|
+
- new Array() captures calls then new Subject({ timeout: 50, appendTo: document.body, textContent: "hi" }) captures n then n.destroy = (...a) => { calls.push(1); return Object.getPrototypeOf(n).destroy.call(n, ...a); } then n.elem.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })) then await new Promise(r => setTimeout(r, 160)) -> calls.length === 1
|
|
23
|
+
|
|
24
|
+
## Severity decides how assertively the notification announces
|
|
25
|
+
|
|
26
|
+
A screen reader interrupts for `alert` and waits for a quiet moment for `status`. Errors and warnings are worth interrupting for; a success or an informational note is not, and interrupting for those trains people to ignore the ones that matter.
|
|
27
|
+
|
|
28
|
+
- `error` and `warning` announce as alerts; every other type announces politely
|
|
29
|
+
- new Subject({ type: "error", content: "m", appendTo: document.body }) captures err then new Subject({ type: "warning", content: "m", appendTo: document.body }) captures warn then new Subject({ type: "success", content: "m", appendTo: document.body }) captures ok then new Subject({ type: "info", content: "m", appendTo: document.body }) captures info -> err.elem.getAttribute("role") === "alert" && warn.elem.getAttribute("role") === "alert" && ok.elem.getAttribute("role") === "status" && info.elem.getAttribute("role") === "status"
|
|
30
|
+
|
|
31
|
+
- a falsy `timeout` means the notification stays until something dismisses it; a positive one dismisses it after that long
|
|
32
|
+
- new Subject({ content: "stay", timeout: 0, appendTo: document.body }) captures kept then new Subject({ content: "go", timeout: 30, appendTo: document.body }) captures going then await new Promise(r => setTimeout(r, 90)) -> kept.elem.isConnected === true && going.elem.isConnected === false
|
|
@@ -7,14 +7,25 @@ Top-level layout component that takes ownership of stylesheet loading. The desig
|
|
|
7
7
|
## Required stylesheets load once - remounting does not re-fetch
|
|
8
8
|
|
|
9
9
|
- Page checks whether each stylesheet is already present before injecting; remounting in a single-page app does not duplicate link elements or re-trigger network requests
|
|
10
|
-
-
|
|
10
|
+
- new Subject({ styleSheets: ["/lld-dedup-probe.css"], appendTo: document.body }) captures first then document.querySelectorAll('link[rel="stylesheet"][href="/lld-dedup-probe.css"]').length captures afterFirst then new Subject({ styleSheets: ["/lld-dedup-probe.css"], appendTo: document.body }) captures second -> afterFirst === 1 && document.querySelectorAll('link[rel="stylesheet"][href="/lld-dedup-probe.css"]').length === 1
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## A stylesheet is scoped by fetching it; otherwise it is linked
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
A caller that needs a stylesheet confined to part of the page gives it a `scope`, and the only way to
|
|
15
|
+
wrap third-party CSS in a selector is to fetch the text and re-emit it. Without a scope there is
|
|
16
|
+
nothing to rewrite, so the ordinary link is used.
|
|
17
|
+
|
|
18
|
+
- an entry with a `scope` is fetched and its rules are emitted inline under that selector; a plain URL becomes a stylesheet link
|
|
19
|
+
- new Subject({ styleSheets: ["/lld-plain.css"], appendTo: document.body }) captures p -> document.querySelectorAll('link[rel="stylesheet"][href="/lld-plain.css"]').length === 1
|
|
16
20
|
|
|
17
21
|
## Page sets a full-height flex baseline at construction
|
|
18
22
|
|
|
19
23
|
- height: 100% and flex display are applied as inline styles at construction
|
|
20
24
|
- does a Page element have height: 100% applied at construction?
|
|
25
|
+
|
|
26
|
+
## A stylesheet list tolerates gaps
|
|
27
|
+
|
|
28
|
+
Stylesheet lists are usually built by concatenating conditionals, so holes in them are normal rather than a mistake. A hole is skipped; the entries around it still load.
|
|
29
|
+
|
|
30
|
+
- falsy entries in `styleSheets` are skipped and the real entries beside them are still injected
|
|
31
|
+
- new Subject({ styleSheets: [null, "/real.css", undefined, ""], appendTo: document.body }) captures p -> Array.from(document.querySelectorAll("link[rel=stylesheet]")).some(l => l.getAttribute("href") === "/real.css")
|
|
@@ -6,14 +6,18 @@ Native popover element with edge-aware placement. The design decision: position
|
|
|
6
6
|
|
|
7
7
|
## Placement adapts to viewport edges at the moment of showing
|
|
8
8
|
|
|
9
|
+
**browser:** true
|
|
10
|
+
|
|
9
11
|
- when the popover would overflow an edge, the position flips; this calculation runs on each `show()` call so it stays accurate as the page scrolls or resizes
|
|
12
|
+
- new Subject({ ...{ maxWidth: 264, maxHeight: 132, state: "manual", outsideClose: false, autoOpen: false, uniqueId: true }, x: 10, y: 10 }) captures near then near.show() then new Subject({ ...{ maxWidth: 264, maxHeight: 132, state: "manual", outsideClose: false, autoOpen: false, uniqueId: true }, x: window.innerWidth - 10, y: 10 }) captures far then far.show() -> near.elem.style.left !== "unset" && far.elem.style.left === "unset" && far.elem.style.right !== "unset"
|
|
10
13
|
|
|
11
14
|
## autoOpen fires after a short delay, not immediately
|
|
12
15
|
|
|
13
16
|
- `autoOpen: true` queues `show()` after 200ms; failing immediately would break when the popover's anchor isn't yet in the DOM
|
|
14
17
|
- does a popover with autoOpen not open synchronously on construction?
|
|
15
|
-
-
|
|
18
|
+
- new Subject({ autoOpen: true, state: "manual", uniqueId: true, appendTo: document.body }) captures cancelled then new Subject({ autoOpen: true, state: "manual", uniqueId: true, appendTo: document.body }) captures control then await new Promise(r => setTimeout(r, 40)) then Object.keys(cancelled.cleanup) captures armed then cancelled.processCleanup() then await new Promise(r => setTimeout(r, 320)) -> armed.includes("autoOpen") && cancelled.elem.style.display !== "block" && control.elem.style.display === "block"
|
|
16
19
|
|
|
17
20
|
## Manual and auto state are distinct dismiss models
|
|
18
21
|
|
|
19
22
|
- `state: 'auto'` delegates dismiss to the platform's native light-dismiss; `state: 'manual'` requires explicit `close()`; the choice belongs to the use site
|
|
23
|
+
- new Subject({ state: "auto", autoOpen: false, uniqueId: true, maxWidth: 264, maxHeight: 132, outsideClose: false }) captures a then new Subject({ state: "manual", autoOpen: false, uniqueId: true, maxWidth: 264, maxHeight: 132, outsideClose: false }) captures m -> a.elem.getAttribute("popover") === "auto" && m.elem.getAttribute("popover") === "manual"
|
|
@@ -85,7 +85,12 @@ class RadioButton extends Component {
|
|
|
85
85
|
new RadioButtonInput({
|
|
86
86
|
tag: 'input',
|
|
87
87
|
type: 'radio',
|
|
88
|
-
|
|
88
|
+
// As an attribute, not a bare option: `value` is an own property on Component, so
|
|
89
|
+
// the plain option set it on the component and never reached the element. Every
|
|
90
|
+
// radio kept the browser default of "on", which made `event.target.value` the
|
|
91
|
+
// same string for all of them and left the `value` setter below unable to match
|
|
92
|
+
// any input -- assigning a value cleared the selection instead of moving it.
|
|
93
|
+
attributes: { value: option?.value || option },
|
|
89
94
|
name: this.uniqueId,
|
|
90
95
|
checked: this.options.value === (option?.value || option),
|
|
91
96
|
onChange: event => {
|
|
@@ -12,4 +12,17 @@ Radio group from an array of options. The design decision: the HTML `name` coord
|
|
|
12
12
|
## Options can separate their stored value from their display label
|
|
13
13
|
|
|
14
14
|
- a string option uses its value as both the label and stored datum; an object with `label` and `value` lets them differ, useful when the stored value (an ID, a code) would be confusing as a visible label
|
|
15
|
-
-
|
|
15
|
+
- new Subject({ options: [{ label: "Visible", value: "v" }], value: "v" }) captures r -> r.elem.textContent.includes("Visible") && r.elem.textContent.includes("v") === false
|
|
16
|
+
|
|
17
|
+
## The selected option is named by `value`, in both directions
|
|
18
|
+
|
|
19
|
+
A radio group has one value, and it is the option's own value -- not the browser's default `on`, which would make every option report the same string. Assigning `value` moves the selection; picking an option reports that option back.
|
|
20
|
+
|
|
21
|
+
- each option's value reaches its input, so the group's value identifies which option is selected
|
|
22
|
+
- new Subject({ options: ["a", "b", "c"], value: "b", appendTo: document.body }) captures r then Array.from(r.elem.querySelectorAll("input")).map(i => i.value + (i.checked ? "*" : "")) captures state -> state.join() === "a,b*,c"
|
|
23
|
+
- assigning `value` moves the selection to the matching option rather than clearing it
|
|
24
|
+
- new Subject({ options: ["a", "b", "c"], value: "b", appendTo: document.body }) captures r then r.options.value = "c" then Array.from(r.elem.querySelectorAll("input")).map(i => i.value + (i.checked ? "*" : "")) captures state -> state.join() === "a,b,c*"
|
|
25
|
+
- choosing an option reports that option's value, not a shared default
|
|
26
|
+
- new Subject({ options: ["a", "b", "c"], value: "b", appendTo: document.body }) captures r then r.elem.querySelectorAll("input")[0] captures first then first.checked = true then first.dispatchEvent(new Event("change", { bubbles: true })) -> r.options.value === "a"
|
|
27
|
+
- an options list that is absent renders an empty group rather than throwing
|
|
28
|
+
- new Subject({ options: undefined, appendTo: document.body }) captures missing then new Subject({ options: null, appendTo: document.body }) captures empty -> missing.elem.querySelectorAll("input").length === 0 && empty.elem.querySelectorAll("input").length === 0
|
|
@@ -135,22 +135,14 @@ class Router extends Component {
|
|
|
135
135
|
|
|
136
136
|
if (this.options.views[route]) {
|
|
137
137
|
this.view = new this.options.views[route]({ appendTo: this.elem, ...this.parseRouteParameters() });
|
|
138
|
+
} else if (
|
|
139
|
+
this.options.defaultPath &&
|
|
140
|
+
this.options.views[this.options.defaultPath] &&
|
|
141
|
+
this.path !== this.options.defaultPath
|
|
142
|
+
) {
|
|
143
|
+
this.path = this.options.defaultPath;
|
|
138
144
|
} else if (this.options.notFound) this.view = new this.options.notFound({ appendTo: this.elem, route });
|
|
139
|
-
else if (this.options.defaultPath && this.path !== this.options.defaultPath) this.path = this.options.defaultPath;
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
export default Router;
|
|
144
|
-
|
|
145
|
-
// Zero-arg scenarios for LLD verification
|
|
146
|
-
export const hashStripsQueryString = () => {
|
|
147
|
-
const r = new Router({ views: { '/path': Component }, autoRender: false });
|
|
148
|
-
window.location.hash = '#/path?query=1&sort=name';
|
|
149
|
-
return r.path;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
export const paramExtractedFromRoute = () => {
|
|
153
|
-
const r = new Router({ views: { '/users/:id': Component }, autoRender: false });
|
|
154
|
-
window.location.hash = '#/users/42';
|
|
155
|
-
return r.parseRouteParameters('/users/42')?.id;
|
|
156
|
-
};
|
|
@@ -6,26 +6,31 @@ Hash-based router that maps URL fragments to component classes. The design decis
|
|
|
6
6
|
|
|
7
7
|
## Query strings are stripped before route matching
|
|
8
8
|
|
|
9
|
-
**method:** `hashStripsQueryString`
|
|
10
|
-
|
|
11
9
|
- `#/path?foo=bar` matches the `/path` route; query strings are stripped before matching so callers write route patterns without accounting for query parameters
|
|
12
|
-
-
|
|
10
|
+
- new Subject({ views: { "/path": null }, autoRender: false }) captures r then window.location.hash = "#/path?foo=bar" -> r.route === "/path"
|
|
13
11
|
|
|
14
12
|
## Route parameters are extracted and passed to the rendered view
|
|
15
13
|
|
|
16
|
-
**method:** `paramExtractedFromRoute`
|
|
17
|
-
|
|
18
14
|
- patterns like `/users/:id` match `/users/42` and produce `{ id: '42' }` for the view without any URL parsing at the view level
|
|
19
|
-
-
|
|
15
|
+
- new Subject({ views: { "/users/:id": null }, autoRender: false }) captures r then r.parseRouteParameters("/users/42") captures p -> p.id === "42"
|
|
16
|
+
- a path that does not fit the pattern produces no parameters rather than partial ones
|
|
17
|
+
- new Array() captures got then (location.hash = "#/things/42") then new Subject({ views: { "/things/:id": class { constructor(o) { got.push(o.id); this.elem = document.createElement("div"); } } }, appendTo: document.body }) captures r -> r.parseRouteParameters("/things/42").id === "42" && Object.keys(r.parseRouteParameters("/nothing/here")).length === 0 && got.join() === "42"
|
|
20
18
|
|
|
21
19
|
## Same-route navigation does not rebuild the view
|
|
22
20
|
|
|
21
|
+
View state is preserved across same-route navigations, so callers do not have to guard against them.
|
|
22
|
+
|
|
23
23
|
- if the new hash resolves to the same route as what's currently rendered, the component skips re-render
|
|
24
|
-
-
|
|
25
|
-
- does navigating to the currently active route leave the rendered view unchanged?
|
|
24
|
+
- new Array() captures renders then window.location.hash = "#/route-a" then new Subject({ views: { "/route-a": Object }, onRenderView: x => renders.push(x) }) captures r then r.view captures firstView then r.renderView() then r.renderView() -> renders.length === 1 && r.view === firstView
|
|
26
25
|
|
|
27
26
|
## Unmatched routes fall back rather than rendering nothing
|
|
28
27
|
|
|
29
|
-
- an unmatched hash tries `defaultPath` before showing the notFound component
|
|
30
|
-
-
|
|
31
|
-
|
|
28
|
+
- an unmatched hash tries `defaultPath` before showing the notFound component; notFound is the last resort, not the first response to a miss
|
|
29
|
+
- new Array() captures seen then window.location.hash = "#/nowhere" then new Subject({ views: { "/known": function (o) { seen.push("known"); } }, defaultPath: "/known", notFound: function (o) { seen.push("notFound"); } }) captures r then r.renderView() -> seen.includes("known") && seen.includes("notFound") === false
|
|
30
|
+
|
|
31
|
+
## Where the path comes from is the only difference between the two modes
|
|
32
|
+
|
|
33
|
+
`hash` mode reads the fragment and `history` mode reads the pathname; everything downstream -- matching, parameters, view construction -- is identical. Keeping the difference to one accessor is what makes the mode a deployment choice rather than a different router.
|
|
34
|
+
|
|
35
|
+
- in hash mode the path is the fragment, normalised and stripped of any query; in history mode it is the pathname
|
|
36
|
+
- new Array() captures seen then (location.hash = "#/things/42") then new Subject({ views: { "/things/:id": class extends Object {} }, appendTo: document.body }) captures hashed then new Subject({ mode: "history", views: {}, appendTo: document.body }) captures historied -> hashed.path === "/things/42" && historied.path === window.location.pathname
|
|
@@ -39,8 +39,21 @@ class Select extends Input {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// Re-apply the current value - option elements may not have existed when value was processed
|
|
43
|
-
|
|
42
|
+
// Re-apply the current value - option elements may not have existed when value was processed.
|
|
43
|
+
//
|
|
44
|
+
// The empty case is excluded because `value` is inherited from Input with a default of
|
|
45
|
+
// '', so by the time this runs there is no way to tell an unset value from one the
|
|
46
|
+
// caller chose. Assigning '' to a select whose options all carry real values sets
|
|
47
|
+
// selectedIndex to -1: nothing is highlighted, and `select.value` reports '' for a
|
|
48
|
+
// control the user sees as populated. Skipping leaves the platform's own selection --
|
|
49
|
+
// the first option -- which is what the control actually displays.
|
|
50
|
+
//
|
|
51
|
+
// An explicit '' is still honoured when an option carries it, since then it names a real
|
|
52
|
+
// choice rather than "no value at all".
|
|
53
|
+
const selected = this.options.value;
|
|
54
|
+
const emptyIsSelectable = () => Array.from(this.elem.options).some(option => option.value === '');
|
|
55
|
+
|
|
56
|
+
if (selected !== undefined && (selected !== '' || emptyIsSelectable())) this.elem.value = selected;
|
|
44
57
|
},
|
|
45
58
|
},
|
|
46
59
|
};
|
|
@@ -53,7 +66,13 @@ class Select extends Input {
|
|
|
53
66
|
// Use elem.options (HTMLOptionsCollection) - works across optgroups
|
|
54
67
|
const selected = Array.from(this.elem.options).find(({ selected }) => selected);
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
if (!selected) return this.elem.value;
|
|
70
|
+
|
|
71
|
+
// option.value is never null/undefined per spec (it's the value attribute, or
|
|
72
|
+
// textContent when absent) - hasAttribute is required to actually reach the label fallback
|
|
73
|
+
if (selected.hasAttribute('value')) return selected.value;
|
|
74
|
+
|
|
75
|
+
return selected.label || selected.textContent;
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
/**
|
|
@@ -8,13 +8,37 @@ Dropdown select that extends Input. The design decision: Select inherits Input's
|
|
|
8
8
|
|
|
9
9
|
- `isDirty`, validations, and onChange all work the same way as Input without any Select-specific API
|
|
10
10
|
- does a Select have isDirty?
|
|
11
|
-
-
|
|
11
|
+
- new Array() captures fired then new Subject({ options: ["a", "b"], onChange: () => fired.push(1) }) captures s then s.elem.value = "b" then s.elem.dispatchEvent(new Event("change", { bubbles: true })) -> fired.length === 1
|
|
12
12
|
|
|
13
13
|
## Options map in order, preserving sequence
|
|
14
14
|
|
|
15
|
+
Options frequently arrive from a fetch, so the list is routinely absent on the first render. A select with nothing to show is a normal state, not an error.
|
|
16
|
+
|
|
17
|
+
- an options list that is absent or not yet loaded renders an empty select rather than throwing, so a caller can pass the value straight through while it resolves
|
|
18
|
+
- new Subject({ options: undefined, appendTo: document.body }) captures missing then new Subject({ options: null, appendTo: document.body }) captures empty -> missing.elem.options.length === 0 && empty.elem.options.length === 0
|
|
19
|
+
|
|
15
20
|
- items in the `options` array become select options in the same order; no automatic sorting or reindexing
|
|
16
|
-
-
|
|
21
|
+
- new Subject({ options: ["alpha", "beta", "gamma"] }) captures s then Array.from(s.elem.querySelectorAll("option")).map(o => o.value) captures rendered -> rendered.join(",") === "alpha,beta,gamma"
|
|
22
|
+
|
|
23
|
+
An entry that carries its own `options` array is a group rather than a choice, so the array is one level of nesting rather than a flat list. Grouping is presentation only -- a grouped option is still an option, and reading `value` must not care which level it came from.
|
|
24
|
+
|
|
25
|
+
- an entry with a nested `options` array becomes an optgroup holding those choices; plain entries stay at the top level, and the value getter reaches a grouped option the same as an ungrouped one
|
|
26
|
+
- new Subject({ options: [{ label: "Group A", options: ["a1", "a2"] }, "loose"], appendTo: document.body }) captures s then Array.from(s.elem.children).map(c => c.tagName) captures shape then s.elem.querySelector("optgroup") captures group -> shape.join(",") === "OPTGROUP,OPTION" && group.label === "Group A" && Array.from(group.children).map(o => o.value).join(",") === "a1,a2"
|
|
27
|
+
- new Subject({ options: [{ label: "Group A", options: ["a1", "a2"] }, "loose"], appendTo: document.body }) captures s then s.value captures fromGroup then s.elem.options[2].selected = true then s.value captures fromTop -> fromGroup === "a1" && fromTop === "loose"
|
|
17
28
|
|
|
18
29
|
## The value getter returns the selected option's value, with label as fallback
|
|
19
30
|
|
|
20
31
|
- `select.value` returns the option's `value` attribute; if absent it falls back to `label`, then `textContent`
|
|
32
|
+
- new Subject({ options: [{ value: "v1", label: "L1" }, { label: "L2" }, { textContent: "T3" }], appendTo: document.body }) captures s then s.elem.options[0].selected = true then s.value captures byAttr then s.elem.options[1].selected = true then s.value captures byLabel then s.elem.options[2].selected = true then s.value captures byText -> byAttr === "v1" && byLabel === "L2" && byText === "T3"
|
|
33
|
+
- new Subject({ options: [], appendTo: document.body }) captures s -> s.value === "" && s.elem.selectedIndex === -1
|
|
34
|
+
|
|
35
|
+
## A select with options always reports the option it is showing
|
|
36
|
+
|
|
37
|
+
`value` is inherited from Input, which defaults it to the empty string, so a caller who never mentions a value is indistinguishable from one who asked for `''`. Treating the two the same deselects every option -- the control still displays its first entry while `select.value` reports nothing, and the mismatch only surfaces when the form is read. What the getter returns has to be the option the user can see.
|
|
38
|
+
|
|
39
|
+
- constructing a select with options and no explicit value reports the first option, matching what the closed control displays; an explicit value is still applied once the options exist
|
|
40
|
+
- new Subject({ options: ["alpha", "beta"], appendTo: document.body }) captures s -> s.value === "alpha" && s.elem.selectedIndex === 0
|
|
41
|
+
- new Subject({ options: [{ value: "v1", label: "L1" }, { value: "v2" }], appendTo: document.body }) captures byAttr then new Subject({ options: [{ label: "L2" }, { label: "L3" }], appendTo: document.body }) captures byLabel then new Subject({ options: [{ textContent: "T3" }, { textContent: "T4" }], appendTo: document.body }) captures byText -> byAttr.value === "v1" && byLabel.value === "L2" && byText.value === "T3"
|
|
42
|
+
- new Subject({ options: ["alpha", "beta"], value: "beta", appendTo: document.body }) captures s -> s.value === "beta" && s.elem.selectedIndex === 1
|
|
43
|
+
- new Subject({ value: "beta", options: ["alpha", "beta"], appendTo: document.body }) captures valueFirst then new Subject({ options: ["alpha", "beta"], value: "beta", appendTo: document.body }) captures optionsFirst -> valueFirst.elem.selectedIndex === 1 && optionsFirst.elem.selectedIndex === 1
|
|
44
|
+
- new Subject({ options: [{ label: "none", value: "" }, "alpha"], value: "", appendTo: document.body }) captures s -> s.value === "" && s.elem.selectedIndex === 0
|
|
@@ -17,6 +17,10 @@ import { Icon } from '../Icon';
|
|
|
17
17
|
* @param {Function} [options.onSort] - Custom sort function, defaults to built-in sorting
|
|
18
18
|
* @param {string} [options.sortProperty] - Currently sorted column key
|
|
19
19
|
* @param {string} [options.sortDirection] - Sort direction ('asc' or 'desc')
|
|
20
|
+
* @param {*} [options.selection] - Caller-owned selection state. The table stores it and re-renders
|
|
21
|
+
* when it is reassigned, but never interprets it; `dataColumn` functions read it back off
|
|
22
|
+
* `table.options.selection` to render per-row state. Mutating a property of it deliberately does
|
|
23
|
+
* not re-render, so toggling one row's checkbox does not rebuild the table under the pointer
|
|
20
24
|
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
21
25
|
* @returns {Table} Table component instance
|
|
22
26
|
*/
|
|
@@ -67,9 +71,14 @@ class Table extends Component {
|
|
|
67
71
|
const columns = (this.options.columns || []).map(column =>
|
|
68
72
|
typeof column === 'string' ? { key: column, content: capitalize(column) } : column,
|
|
69
73
|
);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
// Footer entries pair with columns by key, not by declaration order - an entry may
|
|
75
|
+
// declare its own `key`; otherwise it falls back to the column at the same index, so
|
|
76
|
+
// existing footer configs that never set `key` keep working unless columns are reordered.
|
|
77
|
+
const footer = this.options.footer?.map((column, index) => {
|
|
78
|
+
const normalized = typeof column === 'string' ? { content: capitalize(column) } : column;
|
|
79
|
+
|
|
80
|
+
return { key: columns[index]?.key, ...normalized };
|
|
81
|
+
});
|
|
73
82
|
|
|
74
83
|
this._sortSubscribers?.forEach(sub => sub.destroy?.());
|
|
75
84
|
this._sortSubscribers = null;
|
|
@@ -177,10 +186,16 @@ class Table extends Component {
|
|
|
177
186
|
);
|
|
178
187
|
|
|
179
188
|
if (footer) {
|
|
189
|
+
const footerByKey = new Map(footer.map(footData => [footData.key, footData]));
|
|
190
|
+
|
|
180
191
|
this.tfoot.append(
|
|
181
192
|
new Component(
|
|
182
193
|
{ tag: 'tr' },
|
|
183
|
-
|
|
194
|
+
columns.map(column => {
|
|
195
|
+
const { key: _footKey, ...footOptions } = footerByKey.get(column.key) || {};
|
|
196
|
+
|
|
197
|
+
return new Component({ tag: 'td', ...footOptions });
|
|
198
|
+
}),
|
|
184
199
|
),
|
|
185
200
|
);
|
|
186
201
|
}
|