stimeo-ui 0.8.0 → 0.9.0
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/CHANGELOG.md +89 -0
- package/dist/controllers/bulk_select_controller.d.ts +62 -15
- package/dist/controllers/bulk_select_controller.js +139 -28
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +48 -15
- package/dist/controllers/clipboard_controller.js +102 -20
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +37 -6
- package/dist/controllers/color_picker_controller.js +180 -43
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +25 -9
- package/dist/controllers/data_grid_controller.js +150 -24
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +34 -9
- package/dist/controllers/editable_controller.js +83 -30
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +15 -3
- package/dist/controllers/filter_controller.js +32 -1
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +34 -5
- package/dist/controllers/masonry_controller.js +129 -17
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +4 -1
- package/dist/controllers/otp_controller.js +29 -16
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/reset_before_cache_controller.d.ts +25 -2
- package/dist/controllers/reset_before_cache_controller.js +51 -5
- package/dist/controllers/reset_before_cache_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +23 -7
- package/dist/controllers/resizable_controller.js +128 -55
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/index.js +860 -339
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js +2 -0
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +2 -0
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +22 -22
- package/dist/inspector/manifest.json +12 -8
- package/package.json +1 -1
|
@@ -55,33 +55,64 @@ var EditableController = class extends Controller {
|
|
|
55
55
|
static values = {
|
|
56
56
|
submitOnBlur: { type: Boolean, default: true }
|
|
57
57
|
};
|
|
58
|
-
static actions = ["
|
|
58
|
+
static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
|
|
59
59
|
static events = ["cancel", "change"];
|
|
60
60
|
/** The value captured when edit mode began, used to detect real changes. */
|
|
61
61
|
#previousValue = "";
|
|
62
|
+
/**
|
|
63
|
+
* The value the last save replaced, or `null` when there is nothing to undo.
|
|
64
|
+
* Cleared on connect: a value from before a page restore is unrecoverable, so
|
|
65
|
+
* `revert()` must not resurrect one.
|
|
66
|
+
*/
|
|
67
|
+
#revertValue = null;
|
|
62
68
|
/**
|
|
63
69
|
* Owns IME lifecycle state for the edit surface, so a keydown that belongs to
|
|
64
70
|
* a composition (cancel or confirm) is never treated as an edit command.
|
|
65
71
|
*/
|
|
66
72
|
#composition = new CompositionTracker();
|
|
73
|
+
/**
|
|
74
|
+
* Watches focus leaving the editor from wherever it currently sits.
|
|
75
|
+
*
|
|
76
|
+
* `focusout` bubbles where `blur` does not, so one listener on the root sees
|
|
77
|
+
* every departure — including one from a Save or Cancel button the consumer
|
|
78
|
+
* placed beside the input. Binding the input alone would make the promise
|
|
79
|
+
* "saves wherever focus moved" true only for focus that leaves the input
|
|
80
|
+
* itself, and tabbing straight past an inner button would strand the editor
|
|
81
|
+
* open.
|
|
82
|
+
*/
|
|
83
|
+
#onFocusOut = (event) => {
|
|
84
|
+
const from = event.target;
|
|
85
|
+
if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
|
|
86
|
+
const next = event.relatedTarget;
|
|
87
|
+
if (next instanceof Node && this.element.contains(next)) return;
|
|
88
|
+
if (this.submitOnBlurValue) this.#commit(false);
|
|
89
|
+
};
|
|
67
90
|
/** Establishes the initial display mode (display shown, input hidden). */
|
|
68
91
|
connect() {
|
|
69
92
|
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
93
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
94
|
+
this.#revertValue = null;
|
|
70
95
|
this.#setMode("display");
|
|
71
96
|
}
|
|
72
|
-
/** Releases the composition listeners so nothing outlives the element. */
|
|
97
|
+
/** Releases the composition and focus listeners so nothing outlives the element. */
|
|
73
98
|
disconnect() {
|
|
74
99
|
this.#composition.disconnect();
|
|
100
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
75
101
|
}
|
|
76
102
|
/** Tracks an input added initially or after connect (e.g. a Turbo swap). */
|
|
77
103
|
inputTargetConnected(input) {
|
|
78
104
|
this.#composition.observe(input);
|
|
105
|
+
this.#applyMode();
|
|
79
106
|
}
|
|
80
107
|
/** Removes composition listeners when the active input is replaced or removed. */
|
|
81
108
|
inputTargetDisconnected(input) {
|
|
82
109
|
this.#composition.unobserve(input);
|
|
83
110
|
}
|
|
84
|
-
/**
|
|
111
|
+
/** Re-hides or re-shows a display element that arrived after the mode was set. */
|
|
112
|
+
displayTargetConnected() {
|
|
113
|
+
this.#applyMode();
|
|
114
|
+
}
|
|
115
|
+
/** Enters edit mode: seeds the input from the declared value, focuses, selects. */
|
|
85
116
|
edit() {
|
|
86
117
|
if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
|
|
87
118
|
this.#previousValue = this.#currentValue;
|
|
@@ -90,6 +121,27 @@ var EditableController = class extends Controller {
|
|
|
90
121
|
this.inputTarget.focus();
|
|
91
122
|
this.inputTarget.select();
|
|
92
123
|
}
|
|
124
|
+
/** Commits the edit and returns focus to the display element. */
|
|
125
|
+
save() {
|
|
126
|
+
this.#commit(true);
|
|
127
|
+
}
|
|
128
|
+
/** Discards edits, returns to display mode, and dispatches `cancel`. */
|
|
129
|
+
cancel() {
|
|
130
|
+
if (!this.#isEditing) return;
|
|
131
|
+
this.#setMode("display");
|
|
132
|
+
if (this.hasDisplayTarget) this.displayTarget.focus();
|
|
133
|
+
this.dispatch("cancel", { detail: {} });
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Puts back the value the last save replaced — for a consumer whose server
|
|
137
|
+
* rejected it. Silent by design: a `change` here would re-enter the same
|
|
138
|
+
* handler that asked for the undo. One save, one undo.
|
|
139
|
+
*/
|
|
140
|
+
revert() {
|
|
141
|
+
if (this.#revertValue === null || this.#isEditing) return;
|
|
142
|
+
this.#writeValue(this.#revertValue);
|
|
143
|
+
this.#revertValue = null;
|
|
144
|
+
}
|
|
93
145
|
/** Adds `F2` as an editing entry point alongside the button's native activation. */
|
|
94
146
|
onDisplayKeydown(event) {
|
|
95
147
|
if (event.key === "F2") {
|
|
@@ -103,56 +155,57 @@ var EditableController = class extends Controller {
|
|
|
103
155
|
if (event.key === "Escape") {
|
|
104
156
|
if (event.defaultPrevented) return;
|
|
105
157
|
event.preventDefault();
|
|
106
|
-
this
|
|
158
|
+
this.cancel();
|
|
107
159
|
return;
|
|
108
160
|
}
|
|
109
161
|
if (event.key === "Enter") {
|
|
110
162
|
if (event.defaultPrevented) return;
|
|
111
163
|
if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
|
|
112
164
|
event.preventDefault();
|
|
113
|
-
this.#
|
|
165
|
+
this.#commit(true);
|
|
114
166
|
}
|
|
115
167
|
}
|
|
116
|
-
/** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
|
|
117
|
-
onBlur() {
|
|
118
|
-
if (!this.#isEditing) return;
|
|
119
|
-
if (this.submitOnBlurValue) this.#save(false);
|
|
120
|
-
}
|
|
121
168
|
/**
|
|
122
|
-
* Returns to display mode,
|
|
123
|
-
*
|
|
169
|
+
* Returns to display mode, storing the input's value and dispatching `change`
|
|
170
|
+
* when it differs from where editing began.
|
|
124
171
|
*
|
|
125
172
|
* @param restoreFocus - Move focus back to the display element (explicit
|
|
126
|
-
*
|
|
173
|
+
* commit) rather than honoring the user's new focus target (blur).
|
|
127
174
|
*/
|
|
128
|
-
#
|
|
129
|
-
if (!this.#isEditing) return;
|
|
130
|
-
const value = this.inputTarget.value;
|
|
175
|
+
#commit(restoreFocus) {
|
|
176
|
+
if (!this.#isEditing || !this.hasInputTarget) return;
|
|
177
|
+
const value = this.inputTarget.value.trim();
|
|
131
178
|
const previous = this.#previousValue;
|
|
132
|
-
this
|
|
179
|
+
this.#writeValue(value);
|
|
133
180
|
this.#setMode("display");
|
|
134
|
-
if (restoreFocus) this.displayTarget.focus();
|
|
181
|
+
if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
|
|
135
182
|
if (value !== previous) {
|
|
183
|
+
this.#revertValue = previous;
|
|
136
184
|
this.dispatch("change", { detail: { value, previous } });
|
|
137
185
|
}
|
|
138
186
|
}
|
|
139
|
-
/**
|
|
140
|
-
#
|
|
141
|
-
if (!this
|
|
142
|
-
this
|
|
143
|
-
|
|
144
|
-
|
|
187
|
+
/** Stores `value` wherever the display element declares it. */
|
|
188
|
+
#writeValue(value) {
|
|
189
|
+
if (!this.hasDisplayTarget) return;
|
|
190
|
+
const display = this.displayTarget;
|
|
191
|
+
if (display.hasAttribute("data-value")) display.dataset.value = value;
|
|
192
|
+
else display.textContent = value;
|
|
145
193
|
}
|
|
146
|
-
/**
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
const editing = mode === "editing";
|
|
194
|
+
/** Derives both elements' visibility from the mode currently in the DOM. */
|
|
195
|
+
#applyMode() {
|
|
196
|
+
const editing = this.#isEditing;
|
|
150
197
|
if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
|
|
151
198
|
if (this.hasInputTarget) this.inputTarget.hidden = !editing;
|
|
152
199
|
}
|
|
153
|
-
/**
|
|
200
|
+
/** Records the mode, then brings both elements in line with it. */
|
|
201
|
+
#setMode(mode) {
|
|
202
|
+
this.element.dataset.mode = mode;
|
|
203
|
+
this.#applyMode();
|
|
204
|
+
}
|
|
205
|
+
/** The value the display element declares, trimmed. */
|
|
154
206
|
get #currentValue() {
|
|
155
|
-
|
|
207
|
+
const display = this.displayTarget;
|
|
208
|
+
return (display.dataset.value ?? display.textContent ?? "").trim();
|
|
156
209
|
}
|
|
157
210
|
/** Whether the editing control is a multi-line `<textarea>`. */
|
|
158
211
|
get #isMultiline() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/controllers/editable_controller.ts"],"names":[],"mappings":"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","file":"editable_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\n\n/**\n * Headless, accessible inline-editing behavior.\n *\n * Markup contract (identifier: `stimeo--editable`):\n * <div data-controller=\"stimeo--editable\"\n * data-stimeo--editable-submit-on-blur-value=\"true\">\n * <button type=\"button\" aria-label=\"Edit title\"\n * data-stimeo--editable-target=\"display\"\n * data-action=\"click->stimeo--editable#edit\n * keydown->stimeo--editable#onDisplayKeydown\">Current title</button>\n * <input type=\"text\" aria-label=\"Title\" hidden\n * data-stimeo--editable-target=\"input\"\n * data-action=\"keydown->stimeo--editable#onKeydown\n * blur->stimeo--editable#onBlur\" />\n * </div>\n *\n * There is no dedicated APG pattern; this implements a display ⇄ edit toggle with\n * focus management and keyboard commit/cancel, leaning on native form-control\n * labeling (WCAG 1.3.1 / 4.1.2). The look is the consumer's CSS, keyed off\n * `data-mode`.\n *\n * Behavior provided:\n * - Activating the display element (`Enter`/`Space` via the `<button>`, or `F2`)\n * enters edit mode, focuses the input, and selects its text.\n * - `Enter` (single-line) or `Ctrl+Enter` / `Cmd+Enter` (multiline `<textarea>`)\n * saves; `Escape` cancels. Both return focus to the display element. Keys\n * pressed during an IME composition act on the composition, never the edit:\n * a conversion-cancelling `Escape` keeps editing and a conversion-confirming\n * `Enter` never saves (lifecycle-tracked, covering events that omit\n * `isComposing`).\n * - Losing focus while editing saves when `submitOnBlur` is true (the default),\n * honoring wherever focus moved; when false, editing is kept.\n * - Saving dispatches `stimeo--editable:change` with `{ value, previous }` only\n * when the value actually changed; cancelling dispatches\n * `stimeo--editable:cancel`.\n */\nexport class EditableController extends Controller<HTMLElement> {\n static override targets = [\"display\", \"input\"];\n static override values = {\n submitOnBlur: { type: Boolean, default: true },\n };\n static actions = [\"edit\", \"onBlur\", \"onDisplayKeydown\", \"onKeydown\"] as const;\n static events = [\"cancel\", \"change\"] as const;\n\n declare readonly displayTarget: HTMLElement;\n declare readonly inputTarget: HTMLInputElement | HTMLTextAreaElement;\n declare readonly hasDisplayTarget: boolean;\n declare readonly hasInputTarget: boolean;\n\n declare submitOnBlurValue: boolean;\n\n /** The value captured when edit mode began, used to detect real changes. */\n #previousValue = \"\";\n\n /**\n * Owns IME lifecycle state for the edit surface, so a keydown that belongs to\n * a composition (cancel or confirm) is never treated as an edit command.\n */\n readonly #composition = new CompositionTracker();\n\n /** Establishes the initial display mode (display shown, input hidden). */\n override connect(): void {\n if (this.hasInputTarget) this.#composition.observe(this.inputTarget);\n this.#setMode(\"display\");\n }\n\n /** Releases the composition listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#composition.disconnect();\n }\n\n /** Tracks an input added initially or after connect (e.g. a Turbo swap). */\n inputTargetConnected(input: HTMLElement): void {\n this.#composition.observe(input);\n }\n\n /** Removes composition listeners when the active input is replaced or removed. */\n inputTargetDisconnected(input: HTMLElement): void {\n this.#composition.unobserve(input);\n }\n\n /** Enters edit mode: seeds the input from the display text, focuses, selects. */\n edit(): void {\n if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;\n this.#previousValue = this.#currentValue;\n this.inputTarget.value = this.#previousValue;\n this.#setMode(\"editing\");\n this.inputTarget.focus();\n this.inputTarget.select();\n }\n\n /** Adds `F2` as an editing entry point alongside the button's native activation. */\n onDisplayKeydown(event: KeyboardEvent): void {\n if (event.key === \"F2\") {\n event.preventDefault();\n this.edit();\n }\n }\n\n /** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */\n onKeydown(event: KeyboardEvent): void {\n // Keys fired during IME composition steer the composition (cancel a\n // conversion, confirm a candidate) and must never cancel or save the edit.\n if (this.#composition.isComposing(event)) return;\n if (event.key === \"Escape\") {\n // Leave a press an inner handler already owned.\n if (event.defaultPrevented) return;\n event.preventDefault();\n this.#cancel();\n return;\n }\n if (event.key === \"Enter\") {\n // A descendant widget that already claimed the key (a completion popup\n // confirming its candidate) must not ALSO commit the edit — composition\n // depends on this yield. Kept separate from the Escape guard above, which\n // answers a different question: who owns an Escape press.\n if (event.defaultPrevented) return;\n // A bare Enter inserts a newline in a textarea; only Ctrl+Enter (or Cmd+Enter on\n // macOS, the platform-conventional commit chord) saves there.\n if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;\n event.preventDefault();\n this.#save(true);\n }\n }\n\n /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */\n onBlur(): void {\n if (!this.#isEditing) return;\n if (this.submitOnBlurValue) this.#save(false);\n }\n\n /**\n * Returns to display mode, reflecting the input into the display text and\n * dispatching `change` when the value differs from where editing began.\n *\n * @param restoreFocus - Move focus back to the display element (explicit\n * keyboard commit) rather than honoring the user's new focus target (blur).\n */\n #save(restoreFocus: boolean): void {\n if (!this.#isEditing) return;\n const value = this.inputTarget.value;\n const previous = this.#previousValue;\n this.displayTarget.textContent = value;\n this.#setMode(\"display\");\n if (restoreFocus) this.displayTarget.focus();\n if (value !== previous) {\n this.dispatch(\"change\", { detail: { value, previous } });\n }\n }\n\n /** Discards edits, returns to display mode, and dispatches `cancel`. */\n #cancel(): void {\n if (!this.#isEditing) return;\n this.#setMode(\"display\");\n this.displayTarget.focus();\n this.dispatch(\"cancel\", { detail: {} });\n }\n\n /** Toggles the `data-mode` flag and the `hidden` state of both elements. */\n #setMode(mode: \"display\" | \"editing\"): void {\n this.element.dataset.mode = mode;\n const editing = mode === \"editing\";\n if (this.hasDisplayTarget) this.displayTarget.hidden = editing;\n if (this.hasInputTarget) this.inputTarget.hidden = !editing;\n }\n\n /** Current display text, trimmed — the value shown when not editing. */\n get #currentValue(): string {\n return (this.displayTarget.textContent ?? \"\").trim();\n }\n\n /** Whether the editing control is a multi-line `<textarea>`. */\n get #isMultiline(): boolean {\n return this.inputTarget.tagName === \"TEXTAREA\";\n }\n\n /** Whether the controller is currently in edit mode. */\n get #isEditing(): boolean {\n return this.element.dataset.mode === \"editing\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/controllers/editable_controller.ts"],"names":[],"mappings":"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","file":"editable_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\n\n/**\n * Headless, accessible inline-editing behavior.\n *\n * Markup contract (identifier: `stimeo--editable`):\n * <div data-controller=\"stimeo--editable\"\n * data-stimeo--editable-submit-on-blur-value=\"true\">\n * <button type=\"button\" aria-label=\"Edit title\"\n * data-stimeo--editable-target=\"display\"\n * data-action=\"click->stimeo--editable#edit\n * keydown->stimeo--editable#onDisplayKeydown\">Current title</button>\n * <input type=\"text\" aria-label=\"Title\" hidden\n * data-stimeo--editable-target=\"input\"\n * data-action=\"keydown->stimeo--editable#onKeydown\" />\n * </div>\n *\n * There is no dedicated APG pattern; this implements a display ⇄ edit toggle with\n * focus management and keyboard commit/cancel, leaning on native form-control\n * labeling (WCAG 1.3.1 / 4.1.2). The look is the consumer's CSS, keyed off\n * `data-mode`.\n *\n * Behavior provided:\n * - Activating the display element (`Enter`/`Space` via the `<button>`, or `F2`)\n * enters edit mode, focuses the input, and selects its text.\n * - `Enter` (single-line) or `Ctrl+Enter` / `Cmd+Enter` (multiline `<textarea>`)\n * saves; `Escape` cancels. Both return focus to the display element. Keys\n * pressed during an IME composition act on the composition, never the edit:\n * a conversion-cancelling `Escape` keeps editing and a conversion-confirming\n * `Enter` never saves (lifecycle-tracked, covering events that omit\n * `isComposing`).\n * - Losing focus while editing saves when `submitOnBlur` is true (the default),\n * honoring wherever focus moved. The departure is watched on the controller\n * element, so it is seen from wherever focus sat — the input, or a Save or\n * Cancel button beside it — and no per-control wiring is required. Focus\n * landing on another element inside this controller has not left the edit, so\n * it commits nothing. When `submitOnBlur` is false, editing is kept.\n * - `save()`, `cancel()`, and `revert()` are actions, so a pointer-only user\n * reaches every path a keyboard user does. `revert()` restores the value from\n * before the last save once, for a consumer whose server rejected it.\n * - Saving dispatches `stimeo--editable:change` with `{ value, previous }` only\n * when the value actually changed; cancelling dispatches\n * `stimeo--editable:cancel` with an empty `{}` detail.\n *\n * The value is whatever the display element declares. A `data-value` attribute\n * holds it when the element's text is a rendering of the value rather than the\n * value itself — a formatted date, a grouped number, an icon beside a label.\n * Without that attribute the trimmed text is the value, and it must then be a\n * string the editing control accepts: `<input type=\"date\">` keeps `2026-08-23`\n * but sanitizes `August 23, 2026` away to an empty string. Saving updates\n * whichever of the two carries the value, so a rendered display keeps its markup\n * and the consumer re-renders it from the `change` event.\n */\nexport class EditableController extends Controller<HTMLElement> {\n static override targets = [\"display\", \"input\"];\n static override values = {\n submitOnBlur: { type: Boolean, default: true },\n };\n static actions = [\"cancel\", \"edit\", \"onDisplayKeydown\", \"onKeydown\", \"revert\", \"save\"] as const;\n static events = [\"cancel\", \"change\"] as const;\n\n declare readonly displayTarget: HTMLElement;\n declare readonly inputTarget: HTMLInputElement | HTMLTextAreaElement;\n declare readonly hasDisplayTarget: boolean;\n declare readonly hasInputTarget: boolean;\n\n declare submitOnBlurValue: boolean;\n\n /** The value captured when edit mode began, used to detect real changes. */\n #previousValue = \"\";\n\n /**\n * The value the last save replaced, or `null` when there is nothing to undo.\n * Cleared on connect: a value from before a page restore is unrecoverable, so\n * `revert()` must not resurrect one.\n */\n #revertValue: string | null = null;\n\n /**\n * Owns IME lifecycle state for the edit surface, so a keydown that belongs to\n * a composition (cancel or confirm) is never treated as an edit command.\n */\n readonly #composition = new CompositionTracker();\n\n /**\n * Watches focus leaving the editor from wherever it currently sits.\n *\n * `focusout` bubbles where `blur` does not, so one listener on the root sees\n * every departure — including one from a Save or Cancel button the consumer\n * placed beside the input. Binding the input alone would make the promise\n * \"saves wherever focus moved\" true only for focus that leaves the input\n * itself, and tabbing straight past an inner button would strand the editor\n * open.\n */\n readonly #onFocusOut = (event: FocusEvent): void => {\n // Entering edit mode hides the display element, and hiding the element that\n // holds focus reports a departure with nowhere to go. That hand-off is the\n // edit starting, not the user leaving it, and the input is focused moments\n // later in the same task. The display is hidden for the whole edit, so it\n // can never be the source of a real departure from one.\n const from = event.target;\n if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;\n // Focus reaching another element inside this controller has not left the\n // edit. Without this the commit would beat an inner button's own click —\n // and a Cancel button would save what it promises to discard.\n const next = event.relatedTarget;\n if (next instanceof Node && this.element.contains(next)) return;\n if (this.submitOnBlurValue) this.#commit(false);\n };\n\n /** Establishes the initial display mode (display shown, input hidden). */\n override connect(): void {\n // The tracker is idempotent, so observing here and in the target callback\n // keeps IME tracking correct whichever of the two the framework runs first.\n if (this.hasInputTarget) this.#composition.observe(this.inputTarget);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n this.#revertValue = null;\n this.#setMode(\"display\");\n }\n\n /** Releases the composition and focus listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#composition.disconnect();\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n }\n\n /** Tracks an input added initially or after connect (e.g. a Turbo swap). */\n inputTargetConnected(input: HTMLElement): void {\n this.#composition.observe(input);\n this.#applyMode();\n }\n\n /** Removes composition listeners when the active input is replaced or removed. */\n inputTargetDisconnected(input: HTMLElement): void {\n this.#composition.unobserve(input);\n }\n\n /** Re-hides or re-shows a display element that arrived after the mode was set. */\n displayTargetConnected(): void {\n this.#applyMode();\n }\n\n /** Enters edit mode: seeds the input from the declared value, focuses, selects. */\n edit(): void {\n if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;\n this.#previousValue = this.#currentValue;\n this.inputTarget.value = this.#previousValue;\n this.#setMode(\"editing\");\n this.inputTarget.focus();\n this.inputTarget.select();\n }\n\n /** Commits the edit and returns focus to the display element. */\n save(): void {\n this.#commit(true);\n }\n\n /** Discards edits, returns to display mode, and dispatches `cancel`. */\n cancel(): void {\n if (!this.#isEditing) return;\n this.#setMode(\"display\");\n if (this.hasDisplayTarget) this.displayTarget.focus();\n this.dispatch(\"cancel\", { detail: {} });\n }\n\n /**\n * Puts back the value the last save replaced — for a consumer whose server\n * rejected it. Silent by design: a `change` here would re-enter the same\n * handler that asked for the undo. One save, one undo.\n */\n revert(): void {\n if (this.#revertValue === null || this.#isEditing) return;\n this.#writeValue(this.#revertValue);\n this.#revertValue = null;\n }\n\n /** Adds `F2` as an editing entry point alongside the button's native activation. */\n onDisplayKeydown(event: KeyboardEvent): void {\n if (event.key === \"F2\") {\n event.preventDefault();\n this.edit();\n }\n }\n\n /** Commits on `Enter` (or `Ctrl+Enter` when multiline) and cancels on `Escape`. */\n onKeydown(event: KeyboardEvent): void {\n // Keys fired during IME composition steer the composition (cancel a\n // conversion, confirm a candidate) and must never cancel or save the edit.\n if (this.#composition.isComposing(event)) return;\n if (event.key === \"Escape\") {\n // Leave a press an inner handler already owned.\n if (event.defaultPrevented) return;\n event.preventDefault();\n this.cancel();\n return;\n }\n if (event.key === \"Enter\") {\n // A descendant widget that already claimed the key (a completion popup\n // confirming its candidate) must not ALSO commit the edit — composition\n // depends on this yield. Kept separate from the Escape guard above, which\n // answers a different question: who owns an Escape press.\n if (event.defaultPrevented) return;\n // A bare Enter inserts a newline in a textarea; only Ctrl+Enter (or Cmd+Enter on\n // macOS, the platform-conventional commit chord) saves there.\n if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;\n event.preventDefault();\n this.#commit(true);\n }\n }\n\n /**\n * Returns to display mode, storing the input's value and dispatching `change`\n * when it differs from where editing began.\n *\n * @param restoreFocus - Move focus back to the display element (explicit\n * commit) rather than honoring the user's new focus target (blur).\n */\n #commit(restoreFocus: boolean): void {\n if (!this.#isEditing || !this.hasInputTarget) return;\n // One normalization for all three consumers below, so the stored value, the\n // comparison, and the detail can never disagree about what was saved.\n const value = this.inputTarget.value.trim();\n const previous = this.#previousValue;\n this.#writeValue(value);\n this.#setMode(\"display\");\n if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();\n if (value !== previous) {\n this.#revertValue = previous;\n this.dispatch(\"change\", { detail: { value, previous } });\n }\n }\n\n /** Stores `value` wherever the display element declares it. */\n #writeValue(value: string): void {\n if (!this.hasDisplayTarget) return;\n const display = this.displayTarget;\n // Text that renders the value is the consumer's to compose; overwriting it\n // with the raw value would destroy a rendering this controller cannot rebuild.\n if (display.hasAttribute(\"data-value\")) display.dataset.value = value;\n else display.textContent = value;\n }\n\n /** Derives both elements' visibility from the mode currently in the DOM. */\n #applyMode(): void {\n const editing = this.#isEditing;\n if (this.hasDisplayTarget) this.displayTarget.hidden = editing;\n if (this.hasInputTarget) this.inputTarget.hidden = !editing;\n }\n\n /** Records the mode, then brings both elements in line with it. */\n #setMode(mode: \"display\" | \"editing\"): void {\n this.element.dataset.mode = mode;\n this.#applyMode();\n }\n\n /** The value the display element declares, trimmed. */\n get #currentValue(): string {\n const display = this.displayTarget;\n return (display.dataset.value ?? display.textContent ?? \"\").trim();\n }\n\n /** Whether the editing control is a multi-line `<textarea>`. */\n get #isMultiline(): boolean {\n return this.inputTarget.tagName === \"TEXTAREA\";\n }\n\n /** Whether the controller is currently in edit mode. */\n get #isEditing(): boolean {\n return this.element.dataset.mode === \"editing\";\n }\n}\n"]}
|
|
@@ -30,9 +30,12 @@ type FilterControl = HTMLElement;
|
|
|
30
30
|
*
|
|
31
31
|
* @remarks
|
|
32
32
|
* Behavior only — the consumer owns all styling and the controls' own accessible
|
|
33
|
-
* state (e.g. `aria-pressed`)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* state (e.g. `aria-pressed`); the one exception is `clear`, which writes those
|
|
34
|
+
* controls one way, to off. Visibility is re-derived from the live DOM on
|
|
35
|
+
* `connect()`, on every native `change` that bubbles to the root, on the `apply`
|
|
36
|
+
* and `clear` actions, and when `match` changes at runtime — the declaration
|
|
37
|
+
* decides what is shown, so an element kept across a morph still follows it.
|
|
38
|
+
* Button toggles that emit no native `change` wire their event to the `apply`
|
|
36
39
|
* action (e.g. `stimeo--toggle-group:change->stimeo--filter#apply`).
|
|
37
40
|
*/
|
|
38
41
|
declare class FilterController extends Controller<HTMLElement> {
|
|
@@ -53,6 +56,15 @@ declare class FilterController extends Controller<HTMLElement> {
|
|
|
53
56
|
matchValue: string;
|
|
54
57
|
connect(): void;
|
|
55
58
|
disconnect(): void;
|
|
59
|
+
/**
|
|
60
|
+
* Re-evaluates when the match declaration changes at runtime.
|
|
61
|
+
*
|
|
62
|
+
* The declaration decides which items are shown, so an element kept across a
|
|
63
|
+
* morph — where `connect()` does not run again — still has to follow it instead
|
|
64
|
+
* of waiting for the next control interaction. The evaluation is the ordinary
|
|
65
|
+
* one, `change` included: a declaration swap is an evaluation like any other.
|
|
66
|
+
*/
|
|
67
|
+
matchValueChanged(): void;
|
|
56
68
|
/** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
|
|
57
69
|
apply(): void;
|
|
58
70
|
/** Turns every control off (uncheck / aria-pressed="false") and re-applies. */
|
|
@@ -9,15 +9,46 @@ var FilterController = class extends Controller {
|
|
|
9
9
|
#onChange = () => {
|
|
10
10
|
this.apply();
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* Gates the declaration callback to the connected window.
|
|
14
|
+
*
|
|
15
|
+
* Stimulus delivers a Value callback ahead of `connect()` and again for every
|
|
16
|
+
* runtime change; without the gate, merely connecting would emit an evaluation
|
|
17
|
+
* — and its `change` — before `connect()` runs its own.
|
|
18
|
+
*/
|
|
19
|
+
#connected = false;
|
|
12
20
|
connect() {
|
|
13
|
-
this
|
|
21
|
+
this.#evaluate();
|
|
14
22
|
this.element.addEventListener("change", this.#onChange);
|
|
23
|
+
this.#connected = true;
|
|
15
24
|
}
|
|
16
25
|
disconnect() {
|
|
26
|
+
this.#connected = false;
|
|
17
27
|
this.element.removeEventListener("change", this.#onChange);
|
|
18
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Re-evaluates when the match declaration changes at runtime.
|
|
31
|
+
*
|
|
32
|
+
* The declaration decides which items are shown, so an element kept across a
|
|
33
|
+
* morph — where `connect()` does not run again — still has to follow it instead
|
|
34
|
+
* of waiting for the next control interaction. The evaluation is the ordinary
|
|
35
|
+
* one, `change` included: a declaration swap is an evaluation like any other.
|
|
36
|
+
*/
|
|
37
|
+
matchValueChanged() {
|
|
38
|
+
if (!this.#connected) return;
|
|
39
|
+
this.#evaluate();
|
|
40
|
+
}
|
|
19
41
|
/** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
|
|
20
42
|
apply() {
|
|
43
|
+
this.#evaluate();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The evaluation itself: every item's visibility from the active tokens, then
|
|
47
|
+
* the groups and the empty element, then the `change` event.
|
|
48
|
+
*
|
|
49
|
+
* @stimeoRenderRoot
|
|
50
|
+
*/
|
|
51
|
+
#evaluate() {
|
|
21
52
|
const active = this.#activeTokens();
|
|
22
53
|
let visibleCount = 0;
|
|
23
54
|
for (const item of this.itemTargets) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/filter_controller.ts"],"names":[],"mappings":"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","file":"filter_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** A control whose \"on\" state contributes its token to the active filter set. */\ntype FilterControl = HTMLElement;\n\n/**\n * Headless faceted-filter behavior: shows or hides a collection of items based on the\n * set of currently-active facet tokens, decoupled from how those tokens are toggled\n * (native checkboxes/radios, or button toggles such as `stimeo--toggle-group`).\n *\n * Markup contract (identifier: `stimeo--filter`):\n * <div data-controller=\"stimeo--filter\" data-stimeo--filter-match-value=\"all\">\n * <!-- controls: each contributes its token while \"on\" (aria-pressed / checked) -->\n * <button type=\"button\" aria-pressed=\"false\" data-value=\"apg:dialog\"\n * data-stimeo--filter-target=\"control\">Dialog</button>\n * <!-- items: filtered in/out by their space-separated tokens -->\n * <li data-stimeo--filter-target=\"item\"\n * data-stimeo--filter-tokens=\"apg:dialog overlay\">…</li>\n * <!-- optional: a container hidden once it holds no visible item -->\n * <section data-stimeo--filter-target=\"group\">…</section>\n * <!-- optional: an element revealed when nothing matches -->\n * <p data-stimeo--filter-target=\"empty\" hidden>No matches</p>\n * </div>\n *\n * `match` decides how multiple active tokens combine: `all` (default — an item must\n * carry every active token) or `any` (at least one). With no active token every item\n * is shown. Items are toggled via `hidden`; groups with zero visible items and the\n * `empty` element are kept in sync, and `stimeo--filter:change` is dispatched.\n *\n * `change` dispatches `{ active, visible, total }`.\n *\n * @remarks\n * Behavior only — the consumer owns all styling and the controls' own accessible\n * state (e.g. `aria-pressed`). Visibility is re-derived from the live DOM on\n * `connect()` (Morph/Turbo-safe) and on every native `change` that bubbles to the\n * root; button toggles that emit no native `change` wire their event to the `apply`\n * action (e.g. `stimeo--toggle-group:change->stimeo--filter#apply`).\n */\nexport class FilterController extends Controller<HTMLElement> {\n static override targets = [\"item\", \"control\", \"group\", \"empty\"];\n static override values = { match: { type: String, default: \"all\" } };\n static actions = [\"apply\", \"clear\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare readonly controlTargets: FilterControl[];\n declare readonly groupTargets: HTMLElement[];\n declare readonly emptyTargets: HTMLElement[];\n\n declare matchValue: string;\n\n readonly #onChange = (): void => {\n this.apply();\n };\n\n override connect(): void {\n this.apply();\n this.element.addEventListener(\"change\", this.#onChange);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"change\", this.#onChange);\n }\n\n /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */\n apply(): void {\n const active = this.#activeTokens();\n let visibleCount = 0;\n for (const item of this.itemTargets) {\n const visible = this.#matches(item, active);\n item.hidden = !visible;\n if (visible) visibleCount += 1;\n }\n\n for (const group of this.groupTargets) {\n group.hidden = !this.#hasVisibleItem(group);\n }\n for (const empty of this.emptyTargets) {\n empty.hidden = visibleCount > 0;\n }\n\n this.dispatch(\"change\", {\n detail: { active, visible: visibleCount, total: this.itemTargets.length },\n });\n }\n\n /** Turns every control off (uncheck / aria-pressed=\"false\") and re-applies. */\n clear(): void {\n for (const control of this.controlTargets) {\n if (control instanceof HTMLInputElement) {\n control.checked = false;\n } else if (control.hasAttribute(\"aria-pressed\")) {\n control.setAttribute(\"aria-pressed\", \"false\");\n }\n }\n this.apply();\n }\n\n /** The tokens of every control currently \"on\" (checked or aria-pressed=\"true\"). */\n #activeTokens(): string[] {\n const tokens: string[] = [];\n for (const control of this.controlTargets) {\n if (this.#isOn(control)) tokens.push(this.#tokenOf(control));\n }\n return tokens.filter(Boolean);\n }\n\n /** Whether a control is in its \"on\" state. */\n #isOn(control: FilterControl): boolean {\n if (control instanceof HTMLInputElement) return control.checked;\n return control.getAttribute(\"aria-pressed\") === \"true\";\n }\n\n /** A control's token: explicit `data-stimeo--filter-token`, else `data-value` / value. */\n #tokenOf(control: FilterControl): string {\n const explicit = control.getAttribute(\"data-stimeo--filter-token\") ?? control.dataset.value;\n if (explicit) return explicit;\n return control instanceof HTMLInputElement ? control.value : \"\";\n }\n\n /** Whether an item satisfies the active token set per `match` (empty active → shown). */\n #matches(item: HTMLElement, active: string[]): boolean {\n if (active.length === 0) return true;\n const tokens = this.#tokensOf(item);\n return this.matchValue === \"any\"\n ? active.some((token) => tokens.includes(token))\n : active.every((token) => tokens.includes(token));\n }\n\n /** An item's declared tokens (`data-stimeo--filter-tokens`, space-separated). */\n #tokensOf(item: HTMLElement): string[] {\n return (item.getAttribute(\"data-stimeo--filter-tokens\") ?? \"\").split(/\\s+/).filter(Boolean);\n }\n\n /** Whether a group still contains at least one non-hidden item. */\n #hasVisibleItem(group: HTMLElement): boolean {\n return this.itemTargets.some((item) => !item.hidden && group.contains(item));\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/controllers/filter_controller.ts"],"names":[],"mappings":"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","file":"filter_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** A control whose \"on\" state contributes its token to the active filter set. */\ntype FilterControl = HTMLElement;\n\n/**\n * Headless faceted-filter behavior: shows or hides a collection of items based on the\n * set of currently-active facet tokens, decoupled from how those tokens are toggled\n * (native checkboxes/radios, or button toggles such as `stimeo--toggle-group`).\n *\n * Markup contract (identifier: `stimeo--filter`):\n * <div data-controller=\"stimeo--filter\" data-stimeo--filter-match-value=\"all\">\n * <!-- controls: each contributes its token while \"on\" (aria-pressed / checked) -->\n * <button type=\"button\" aria-pressed=\"false\" data-value=\"apg:dialog\"\n * data-stimeo--filter-target=\"control\">Dialog</button>\n * <!-- items: filtered in/out by their space-separated tokens -->\n * <li data-stimeo--filter-target=\"item\"\n * data-stimeo--filter-tokens=\"apg:dialog overlay\">…</li>\n * <!-- optional: a container hidden once it holds no visible item -->\n * <section data-stimeo--filter-target=\"group\">…</section>\n * <!-- optional: an element revealed when nothing matches -->\n * <p data-stimeo--filter-target=\"empty\" hidden>No matches</p>\n * </div>\n *\n * `match` decides how multiple active tokens combine: `all` (default — an item must\n * carry every active token) or `any` (at least one). With no active token every item\n * is shown. Items are toggled via `hidden`; groups with zero visible items and the\n * `empty` element are kept in sync, and `stimeo--filter:change` is dispatched.\n *\n * `change` dispatches `{ active, visible, total }`.\n *\n * @remarks\n * Behavior only — the consumer owns all styling and the controls' own accessible\n * state (e.g. `aria-pressed`); the one exception is `clear`, which writes those\n * controls one way, to off. Visibility is re-derived from the live DOM on\n * `connect()`, on every native `change` that bubbles to the root, on the `apply`\n * and `clear` actions, and when `match` changes at runtime — the declaration\n * decides what is shown, so an element kept across a morph still follows it.\n * Button toggles that emit no native `change` wire their event to the `apply`\n * action (e.g. `stimeo--toggle-group:change->stimeo--filter#apply`).\n */\nexport class FilterController extends Controller<HTMLElement> {\n static override targets = [\"item\", \"control\", \"group\", \"empty\"];\n static override values = { match: { type: String, default: \"all\" } };\n static actions = [\"apply\", \"clear\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n declare readonly controlTargets: FilterControl[];\n declare readonly groupTargets: HTMLElement[];\n declare readonly emptyTargets: HTMLElement[];\n\n declare matchValue: string;\n\n readonly #onChange = (): void => {\n this.apply();\n };\n\n /**\n * Gates the declaration callback to the connected window.\n *\n * Stimulus delivers a Value callback ahead of `connect()` and again for every\n * runtime change; without the gate, merely connecting would emit an evaluation\n * — and its `change` — before `connect()` runs its own.\n */\n #connected = false;\n\n override connect(): void {\n this.#evaluate();\n this.element.addEventListener(\"change\", this.#onChange);\n this.#connected = true;\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.element.removeEventListener(\"change\", this.#onChange);\n }\n\n /**\n * Re-evaluates when the match declaration changes at runtime.\n *\n * The declaration decides which items are shown, so an element kept across a\n * morph — where `connect()` does not run again — still has to follow it instead\n * of waiting for the next control interaction. The evaluation is the ordinary\n * one, `change` included: a declaration swap is an evaluation like any other.\n */\n matchValueChanged(): void {\n if (!this.#connected) return;\n this.#evaluate();\n }\n\n /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */\n apply(): void {\n this.#evaluate();\n }\n\n /**\n * The evaluation itself: every item's visibility from the active tokens, then\n * the groups and the empty element, then the `change` event.\n *\n * @stimeoRenderRoot\n */\n #evaluate(): void {\n const active = this.#activeTokens();\n let visibleCount = 0;\n for (const item of this.itemTargets) {\n const visible = this.#matches(item, active);\n item.hidden = !visible;\n if (visible) visibleCount += 1;\n }\n\n for (const group of this.groupTargets) {\n group.hidden = !this.#hasVisibleItem(group);\n }\n for (const empty of this.emptyTargets) {\n empty.hidden = visibleCount > 0;\n }\n\n this.dispatch(\"change\", {\n detail: { active, visible: visibleCount, total: this.itemTargets.length },\n });\n }\n\n /** Turns every control off (uncheck / aria-pressed=\"false\") and re-applies. */\n clear(): void {\n for (const control of this.controlTargets) {\n if (control instanceof HTMLInputElement) {\n control.checked = false;\n } else if (control.hasAttribute(\"aria-pressed\")) {\n control.setAttribute(\"aria-pressed\", \"false\");\n }\n }\n this.apply();\n }\n\n /** The tokens of every control currently \"on\" (checked or aria-pressed=\"true\"). */\n #activeTokens(): string[] {\n const tokens: string[] = [];\n for (const control of this.controlTargets) {\n if (this.#isOn(control)) tokens.push(this.#tokenOf(control));\n }\n return tokens.filter(Boolean);\n }\n\n /** Whether a control is in its \"on\" state. */\n #isOn(control: FilterControl): boolean {\n if (control instanceof HTMLInputElement) return control.checked;\n return control.getAttribute(\"aria-pressed\") === \"true\";\n }\n\n /** A control's token: explicit `data-stimeo--filter-token`, else `data-value` / value. */\n #tokenOf(control: FilterControl): string {\n const explicit = control.getAttribute(\"data-stimeo--filter-token\") ?? control.dataset.value;\n if (explicit) return explicit;\n return control instanceof HTMLInputElement ? control.value : \"\";\n }\n\n /** Whether an item satisfies the active token set per `match` (empty active → shown). */\n #matches(item: HTMLElement, active: string[]): boolean {\n if (active.length === 0) return true;\n const tokens = this.#tokensOf(item);\n return this.matchValue === \"any\"\n ? active.some((token) => tokens.includes(token))\n : active.every((token) => tokens.includes(token));\n }\n\n /** An item's declared tokens (`data-stimeo--filter-tokens`, space-separated). */\n #tokensOf(item: HTMLElement): string[] {\n return (item.getAttribute(\"data-stimeo--filter-tokens\") ?? \"\").split(/\\s+/).filter(Boolean);\n }\n\n /** Whether a group still contains at least one non-hidden item. */\n #hasVisibleItem(group: HTMLElement): boolean {\n return this.itemTargets.some((item) => !item.hidden && group.contains(item));\n }\n}\n"]}
|
|
@@ -19,16 +19,30 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
19
19
|
* element as the `--stimeo--masonry-columns` custom property and each item gets a
|
|
20
20
|
* `data-column` index, so the consumer's CSS owns the actual placement.
|
|
21
21
|
*
|
|
22
|
-
* `layout` dispatches `{ columns: number }
|
|
22
|
+
* `layout` dispatches `{ columns: number }` whenever the published result moves —
|
|
23
|
+
* the column count changed, or some item landed in a different column. A pass that
|
|
24
|
+
* reproduces the previous result stays silent.
|
|
23
25
|
*
|
|
24
26
|
* @remarks
|
|
25
27
|
* Behavior only. **DOM order is never changed** — reading order and focus order
|
|
26
28
|
* stay the source markup order (WCAG 1.3.2). The visual packing is purely the
|
|
27
29
|
* column assignment a consumer reads from `data-column`; this controller writes no
|
|
28
|
-
* positioning styles.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* positioning styles. Use only for independent cards whose visual order carries no
|
|
31
|
+
* meaning.
|
|
32
|
+
*
|
|
33
|
+
* Re-layout runs on connect, on resize ({@link LayoutObserver}), on item
|
|
34
|
+
* add/remove ({@link MutationObserver}), on an item joining or leaving the target
|
|
35
|
+
* set, when a declared number changes, and when a descendant resource loads.
|
|
36
|
+
* Everything but the first pass is folded into one microtask, so a burst of
|
|
37
|
+
* triggers costs one pass. The observers, the `load` listener and any pending pass
|
|
38
|
+
* are released on `disconnect()` (Turbo navigation included).
|
|
39
|
+
*
|
|
40
|
+
* Consumer contract:
|
|
41
|
+
* - A declaration that cannot be read as a number (`"240px"`, an infinity) falls
|
|
42
|
+
* back to that Value's default and the grid keeps working; `0` and negatives are
|
|
43
|
+
* readable numbers and collapse to a single column instead.
|
|
44
|
+
* - `data-column` belongs to this controller: it is written on every item it owns
|
|
45
|
+
* and taken back from an element that stops being one.
|
|
32
46
|
*/
|
|
33
47
|
declare class MasonryController extends Controller<HTMLElement> {
|
|
34
48
|
#private;
|
|
@@ -47,6 +61,21 @@ declare class MasonryController extends Controller<HTMLElement> {
|
|
|
47
61
|
readonly itemTargets: HTMLElement[];
|
|
48
62
|
minColumnWidthValue: number;
|
|
49
63
|
gapValue: number;
|
|
64
|
+
/** Resolves the declared column width once, falling back when it is unreadable. */
|
|
65
|
+
minColumnWidthValueChanged(): void;
|
|
66
|
+
/** Resolves the declared gap once, falling back when it is unreadable. */
|
|
67
|
+
gapValueChanged(): void;
|
|
68
|
+
/** Packs an element that became an item without moving in the DOM. */
|
|
69
|
+
itemTargetConnected(): void;
|
|
70
|
+
/**
|
|
71
|
+
* Queues the column hook of an element that stopped being an item for removal.
|
|
72
|
+
*
|
|
73
|
+
* The removal is queued rather than immediate because teardown reports every
|
|
74
|
+
* target as disconnected: doing it here would strip the whole grid just before
|
|
75
|
+
* a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
|
|
76
|
+
* with the pass, so only a genuine target change reaches it.
|
|
77
|
+
*/
|
|
78
|
+
itemTargetDisconnected(item: HTMLElement): void;
|
|
50
79
|
/** Observes size/content changes and performs the first layout pass. */
|
|
51
80
|
connect(): void;
|
|
52
81
|
/** Releases both observers and the load listener so nothing fires after detach. */
|