stimeo-ui 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/bulk_select_controller.js +139 -28
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/clipboard_controller.js +102 -20
- data/dist/controllers/color_picker_controller.js +180 -43
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/data_grid_controller.js +150 -24
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/editable_controller.js +83 -30
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/filter_controller.js +32 -1
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/masonry_controller.js +129 -17
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +485 -114
- data/dist/controllers/reset_before_cache_controller.js +51 -5
- data/dist/controllers/resizable_controller.js +128 -55
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +2764 -923
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -18,6 +18,56 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
18
18
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// src/utils/interactive_host.ts
|
|
22
|
+
var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
|
|
23
|
+
function isInteractiveHost(element) {
|
|
24
|
+
if (element.matches(INTERACTIVE_HOST_SELECTOR)) return true;
|
|
25
|
+
let current = element;
|
|
26
|
+
while (current) {
|
|
27
|
+
const raw = current.getAttribute("contenteditable");
|
|
28
|
+
if (raw !== null) {
|
|
29
|
+
const value = raw.trim().toLowerCase();
|
|
30
|
+
if (value === "false") return false;
|
|
31
|
+
if (value === "" || value === "true" || value === "plaintext-only") return true;
|
|
32
|
+
}
|
|
33
|
+
current = current.parentElement;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/utils/microtask_coalescer.ts
|
|
39
|
+
var MicrotaskCoalescer = class {
|
|
40
|
+
#run;
|
|
41
|
+
#queued = false;
|
|
42
|
+
#active = false;
|
|
43
|
+
#generation = 0;
|
|
44
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
45
|
+
constructor(run) {
|
|
46
|
+
this.#run = run;
|
|
47
|
+
}
|
|
48
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
49
|
+
activate() {
|
|
50
|
+
this.#active = true;
|
|
51
|
+
}
|
|
52
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
53
|
+
cancel() {
|
|
54
|
+
this.#active = false;
|
|
55
|
+
this.#queued = false;
|
|
56
|
+
this.#generation += 1;
|
|
57
|
+
}
|
|
58
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
59
|
+
schedule() {
|
|
60
|
+
if (!this.#active || this.#queued) return;
|
|
61
|
+
this.#queued = true;
|
|
62
|
+
const generation = this.#generation;
|
|
63
|
+
queueMicrotask(() => {
|
|
64
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
65
|
+
this.#queued = false;
|
|
66
|
+
this.#run();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
21
71
|
// src/controllers/data_grid_controller.ts
|
|
22
72
|
var SORT_CYCLE = ["none", "ascending", "descending"];
|
|
23
73
|
function nextSortDirection(current) {
|
|
@@ -32,8 +82,15 @@ var DataGridController = class extends Controller {
|
|
|
32
82
|
};
|
|
33
83
|
static actions = ["onKeydown", "sort", "toggleSelect"];
|
|
34
84
|
static events = ["selectionchange", "sort"];
|
|
35
|
-
/**
|
|
36
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Collapses the per-element target callbacks of one DOM mutation into a single
|
|
87
|
+
* baseline pass, and refuses to run before `connect()` or after `disconnect()`.
|
|
88
|
+
*
|
|
89
|
+
* Stimulus reports every target one at a time, so an ungated pass would re-walk
|
|
90
|
+
* the whole grid once per authored cell on mount and once per streamed cell
|
|
91
|
+
* afterwards — quadratic in the cell count both times.
|
|
92
|
+
*/
|
|
93
|
+
#reconcile = new MicrotaskCoalescer(() => this.#restoreBaseline());
|
|
37
94
|
/**
|
|
38
95
|
* Establishes a single tab stop across all navigable cells/headers and brings
|
|
39
96
|
* the rows to their baseline.
|
|
@@ -44,15 +101,30 @@ var DataGridController = class extends Controller {
|
|
|
44
101
|
* attribute, so the Value callback does not fire a second time.
|
|
45
102
|
*/
|
|
46
103
|
connect() {
|
|
104
|
+
this.#restoreBaseline();
|
|
105
|
+
this.#reconcile.activate();
|
|
106
|
+
}
|
|
107
|
+
/** Closes the reconcile window so a queued pass cannot run against a detached tree. */
|
|
108
|
+
disconnect() {
|
|
109
|
+
this.#reconcile.cancel();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rebuilds both DOM-owned baselines from the live grid: exactly one navigable
|
|
113
|
+
* cell is in the Tab sequence, and every selectable row carries an explicit
|
|
114
|
+
* `aria-selected`.
|
|
115
|
+
*
|
|
116
|
+
* The tab stop keeps whichever cell already holds it, so a rebuild triggered by
|
|
117
|
+
* an unrelated row arriving does not throw the user's position away; only when
|
|
118
|
+
* no cell holds it — the grid is fresh, or the holder was removed — does the
|
|
119
|
+
* first navigable cell take over. Without that fallback a grid whose active row
|
|
120
|
+
* is removed keeps every cell at `-1` and drops out of the Tab sequence
|
|
121
|
+
* entirely.
|
|
122
|
+
*/
|
|
123
|
+
#restoreBaseline() {
|
|
47
124
|
const cells = this.#navigableCells();
|
|
48
125
|
const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
|
|
49
|
-
this.#setActiveCell(active, { focus: false });
|
|
126
|
+
if (active) this.#setActiveCell(active, { focus: false }, cells);
|
|
50
127
|
this.#normalizeSelection();
|
|
51
|
-
this.#connected = true;
|
|
52
|
-
}
|
|
53
|
-
/** Reopens the row callback for the next mount. */
|
|
54
|
-
disconnect() {
|
|
55
|
-
this.#connected = false;
|
|
56
128
|
}
|
|
57
129
|
/**
|
|
58
130
|
* Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
|
|
@@ -63,16 +135,25 @@ var DataGridController = class extends Controller {
|
|
|
63
135
|
this.#syncSelectable();
|
|
64
136
|
this.#normalizeSelection();
|
|
65
137
|
}
|
|
66
|
-
/**
|
|
67
|
-
* Re-establishes the row baseline for a row added after connect.
|
|
68
|
-
*
|
|
69
|
-
* Each pass walks every row, and Stimulus reports the authored rows one by one
|
|
70
|
-
* before `connect()`, so the mount is gated to keep it linear in the row count
|
|
71
|
-
* rather than quadratic; `connect()` runs the single baseline pass instead.
|
|
72
|
-
*/
|
|
138
|
+
/** Re-establishes the baselines for a row added after connect. */
|
|
73
139
|
rowTargetConnected() {
|
|
74
|
-
|
|
75
|
-
|
|
140
|
+
this.#reconcile.schedule();
|
|
141
|
+
}
|
|
142
|
+
/** Re-establishes the tab stop when a cell joins the grid after connect. */
|
|
143
|
+
cellTargetConnected() {
|
|
144
|
+
this.#reconcile.schedule();
|
|
145
|
+
}
|
|
146
|
+
/** Re-establishes the tab stop when a cell leaves the grid. */
|
|
147
|
+
cellTargetDisconnected() {
|
|
148
|
+
this.#reconcile.schedule();
|
|
149
|
+
}
|
|
150
|
+
/** Re-establishes the tab stop when a header joins the grid after connect. */
|
|
151
|
+
columnHeaderTargetConnected() {
|
|
152
|
+
this.#reconcile.schedule();
|
|
153
|
+
}
|
|
154
|
+
/** Re-establishes the tab stop when a header leaves the grid. */
|
|
155
|
+
columnHeaderTargetDisconnected() {
|
|
156
|
+
this.#reconcile.schedule();
|
|
76
157
|
}
|
|
77
158
|
/**
|
|
78
159
|
* Brings the authored rows to the shape the APG requires, without changing
|
|
@@ -117,6 +198,9 @@ var DataGridController = class extends Controller {
|
|
|
117
198
|
sort(event) {
|
|
118
199
|
const header = event.currentTarget;
|
|
119
200
|
if (!this.columnHeaderTargets.includes(header)) return;
|
|
201
|
+
if (event.defaultPrevented) return;
|
|
202
|
+
const control = this.#claimingControl(event, header);
|
|
203
|
+
if (control && !(control instanceof HTMLButtonElement)) return;
|
|
120
204
|
const direction = nextSortDirection(header.getAttribute("aria-sort") ?? "none");
|
|
121
205
|
for (const other of this.columnHeaderTargets) {
|
|
122
206
|
other.setAttribute("aria-sort", other === header ? direction : "none");
|
|
@@ -127,14 +211,19 @@ var DataGridController = class extends Controller {
|
|
|
127
211
|
/** Toggles selection of the row owning the event target. Bound optionally. */
|
|
128
212
|
toggleSelect(event) {
|
|
129
213
|
if (this.selectionValue === "none") return;
|
|
130
|
-
|
|
214
|
+
if (event.defaultPrevented) return;
|
|
215
|
+
const host = event.currentTarget;
|
|
216
|
+
if (this.#claimedByDescendant(event, host)) return;
|
|
217
|
+
const row = host.closest("[role='row']");
|
|
131
218
|
if (row && this.rowTargets.includes(row)) this.#toggleRow(row);
|
|
132
219
|
}
|
|
133
220
|
/** Grid navigation + sort/select activation. Bound to cells and headers. */
|
|
134
221
|
onKeydown(event) {
|
|
135
222
|
if (event.defaultPrevented) return;
|
|
136
223
|
if (isReservedArrowChord(event)) return;
|
|
224
|
+
if (event.isComposing) return;
|
|
137
225
|
const cell = event.currentTarget;
|
|
226
|
+
if (this.#claimedByDescendant(event, cell)) return;
|
|
138
227
|
const matrix = this.#matrix();
|
|
139
228
|
const position = this.#locate(matrix, cell);
|
|
140
229
|
if (!position) return;
|
|
@@ -170,9 +259,35 @@ var DataGridController = class extends Controller {
|
|
|
170
259
|
}
|
|
171
260
|
if (target) {
|
|
172
261
|
event.preventDefault();
|
|
173
|
-
this.#setActiveCell(target, { focus: true });
|
|
262
|
+
this.#setActiveCell(target, { focus: true }, matrix.flat());
|
|
174
263
|
}
|
|
175
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* Whether the event was addressed to a control inside `host` rather than to the
|
|
267
|
+
* grid.
|
|
268
|
+
*
|
|
269
|
+
* Cells and headers hold consumer markup, and APG's grid pattern expects that
|
|
270
|
+
* markup to include working controls — a row action button, an inline editor.
|
|
271
|
+
* Those own their own keystrokes and clicks, so the grid stands down entirely
|
|
272
|
+
* rather than acting in parallel. An editable host (its `contenteditable` state
|
|
273
|
+
* is inherited, so the walk is explicit) counts the same way.
|
|
274
|
+
*/
|
|
275
|
+
#claimedByDescendant(event, host) {
|
|
276
|
+
return this.#claimingControl(event, host) !== null;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The nested control this event belongs to, or `null` when the host owns it.
|
|
280
|
+
*
|
|
281
|
+
* Naming the control, rather than answering yes or no, is what lets the click
|
|
282
|
+
* path treat a sortable header's `<button>` as the activation it is while every
|
|
283
|
+
* other control still takes the event away.
|
|
284
|
+
*/
|
|
285
|
+
#claimingControl(event, host) {
|
|
286
|
+
const source = event.target;
|
|
287
|
+
const control = source.closest(INTERACTIVE_HOST_SELECTOR);
|
|
288
|
+
if (control && host.contains(control)) return control;
|
|
289
|
+
return isInteractiveHost(source) ? source : null;
|
|
290
|
+
}
|
|
176
291
|
/** Performs a header's sort or a cell row's selection toggle on activation. */
|
|
177
292
|
#activate(cell) {
|
|
178
293
|
if (this.columnHeaderTargets.includes(cell)) {
|
|
@@ -203,11 +318,22 @@ var DataGridController = class extends Controller {
|
|
|
203
318
|
const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
|
|
204
319
|
this.dispatch("selectionchange", { detail: { rows } });
|
|
205
320
|
}
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Makes `cell` the single tabbable cell (roving) and optionally focuses it.
|
|
323
|
+
*
|
|
324
|
+
* `cells` lets a caller that already walked the grid hand its collection over,
|
|
325
|
+
* so one keystroke rebuilds the matrix once instead of twice. The write is
|
|
326
|
+
* skipped where the attribute already holds the wanted value — comparing the
|
|
327
|
+
* attribute rather than the IDL property, because a cell with no `tabindex` at
|
|
328
|
+
* all reports `-1` and would then never receive the attribute it needs to be
|
|
329
|
+
* focusable.
|
|
330
|
+
*/
|
|
331
|
+
#setActiveCell(cell, { focus }, cells) {
|
|
332
|
+
for (const candidate of cells ?? this.#navigableCells()) {
|
|
333
|
+
const wanted = candidate === cell ? "0" : "-1";
|
|
334
|
+
if (candidate.getAttribute("tabindex") !== wanted) {
|
|
335
|
+
candidate.setAttribute("tabindex", wanted);
|
|
336
|
+
}
|
|
211
337
|
}
|
|
212
338
|
if (focus) cell.focus();
|
|
213
339
|
}
|
|
@@ -111,10 +111,10 @@ var DirectUploadController = class extends Controller {
|
|
|
111
111
|
announceErrorText: { type: String, default: "" },
|
|
112
112
|
scope: { type: String, default: "" }
|
|
113
113
|
};
|
|
114
|
-
static events = ["progress", "done", "error"];
|
|
114
|
+
static events = ["progress", "done", "error", "reconcile"];
|
|
115
115
|
#timeouts = new SafeTimeout();
|
|
116
116
|
#rows = /* @__PURE__ */ new Map();
|
|
117
|
-
#beforeCache = new BeforeCacheReset(() => this.#
|
|
117
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
118
118
|
/** The validated `scope` selector; a broken declaration falls back to `""`. */
|
|
119
119
|
#scopeSelector = "";
|
|
120
120
|
#onInitialize = (event) => {
|
|
@@ -314,6 +314,16 @@ var DirectUploadController = class extends Controller {
|
|
|
314
314
|
* Returns the widget to its pre-upload state just before Turbo caches the
|
|
315
315
|
* page, so the snapshot never replays rows for uploads that cannot resume.
|
|
316
316
|
*/
|
|
317
|
+
/**
|
|
318
|
+
* Rewinds for the snapshot and reports what that discarded. An upload in flight
|
|
319
|
+
* cannot survive the navigation, so a consumer mirroring the rows would keep a
|
|
320
|
+
* progress bar that never resolves.
|
|
321
|
+
*/
|
|
322
|
+
#rewindForCache() {
|
|
323
|
+
const ids = [...this.#rows.keys()];
|
|
324
|
+
this.#reset();
|
|
325
|
+
if (ids.length > 0) this.dispatch("reconcile", { detail: { ids } });
|
|
326
|
+
}
|
|
317
327
|
#reset() {
|
|
318
328
|
for (const row of this.#rows.values()) row.remove();
|
|
319
329
|
this.#rows.clear();
|
|
@@ -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() {
|