@nyaruka/temba-components 0.156.16 → 0.156.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.156.16",
3
+ "version": "0.156.18",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -562,6 +562,14 @@ export class Options extends RapidElement {
562
562
  return;
563
563
  }
564
564
 
565
+ // Don't intercept keys when the popup isn't visible to the user. The
566
+ // element can still be in the layout tree (so offsetParent is non-null)
567
+ // while being hidden via opacity/pointer-events, in which case handling
568
+ // keys would steal them from the underlying input.
569
+ if (!this.block && !this.visible) {
570
+ return;
571
+ }
572
+
565
573
  // Only intercept keys when the event originated within our owning component.
566
574
  // Without this, any temba-options with populated options would swallow arrow
567
575
  // keys document-wide, breaking cursor movement in unrelated text editors.
@@ -1,8 +1,14 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Cursor management utilities for contenteditable
3
- // Newlines are represented as \n characters inside <span class="tok-newline">
4
- // elements, so they're handled as regular text by cursor utilities.
5
- // Browser-added <br> artifacts are ignored (treated as zero-length).
2
+ // Cursor management utilities for contenteditable.
3
+ //
4
+ // The DOM produced by our renderer is a flat list of <span> tokens (newlines
5
+ // are "\n" characters inside <span class="tok-newline">) plus an optional
6
+ // trailing <br data-sentinel> that exists only so Firefox renders an empty
7
+ // final line. Browser-inserted structures (e.g. from yank/paste) are also
8
+ // possible: a bare <br> represents a real newline, and a <div> or <p> at the
9
+ // editable's root level represents a line block. Both are translated to "\n"
10
+ // when computing plain-text offsets so the rebuild after handleInput sees the
11
+ // correct value.
6
12
  // ---------------------------------------------------------------------------
7
13
 
8
14
  /** Gets the Selection object, handling shadow DOM. */
@@ -14,20 +20,51 @@ export function getSelectionFromRoot(element: HTMLElement): Selection | null {
14
20
  return window.getSelection();
15
21
  }
16
22
 
17
- /** Returns the plain-text length of a DOM node. Ignores browser <br> artifacts. */
18
- function nodeTextLength(node: Node): number {
23
+ function isSentinelBr(node: Node): boolean {
24
+ return (
25
+ node.nodeName === 'BR' &&
26
+ typeof (node as Element).hasAttribute === 'function' &&
27
+ (node as Element).hasAttribute('data-sentinel')
28
+ );
29
+ }
30
+
31
+ function isBlockElement(node: Node): boolean {
32
+ return node.nodeName === 'DIV' || node.nodeName === 'P';
33
+ }
34
+
35
+ /** Plain-text contribution of a node and its descendants. */
36
+ function textOfNode(node: Node): string {
19
37
  if (node.nodeType === Node.TEXT_NODE) {
20
- return node.textContent.length;
38
+ return node.textContent || '';
21
39
  }
22
- // Ignore browser-added <br> artifacts
23
40
  if (node.nodeName === 'BR') {
24
- return 0;
41
+ return isSentinelBr(node) ? '' : '\n';
25
42
  }
26
- let len = 0;
27
- for (const child of Array.from(node.childNodes)) {
28
- len += nodeTextLength(child);
43
+ if (node.nodeType !== Node.ELEMENT_NODE) {
44
+ return '';
29
45
  }
30
- return len;
46
+ return textOfChildren(node);
47
+ }
48
+
49
+ /** Plain-text contribution of an element's children, with block-prefix \n. */
50
+ function textOfChildren(parent: Node): string {
51
+ let text = '';
52
+ let hasContent = false;
53
+ for (const child of Array.from(parent.childNodes)) {
54
+ if (
55
+ child.nodeType === Node.ELEMENT_NODE &&
56
+ isBlockElement(child) &&
57
+ hasContent
58
+ ) {
59
+ text += '\n';
60
+ }
61
+ const piece = textOfNode(child);
62
+ text += piece;
63
+ if (piece.length > 0) {
64
+ hasContent = true;
65
+ }
66
+ }
67
+ return text;
31
68
  }
32
69
 
33
70
  /** Converts a DOM selection position (container + offset) to a plain-text offset. */
@@ -36,38 +73,70 @@ function domPositionToTextOffset(
36
73
  targetContainer: Node,
37
74
  targetOffset: number
38
75
  ): number {
39
- let total = 0;
76
+ // Build the text from `root` up to (target, targetOffset) and return its
77
+ // length. This mirrors textOfChildren so block-prefix newlines and BR
78
+ // newlines are accounted for the same way when reading and writing.
79
+ let text = '';
80
+ let stopped = false;
81
+
82
+ const walk = (node: Node): void => {
83
+ if (stopped) return;
40
84
 
41
- const walk = (node: Node): boolean => {
42
85
  if (node === targetContainer) {
43
86
  if (node.nodeType === Node.TEXT_NODE) {
44
- total += targetOffset;
45
- } else {
46
- // offset is a child index
47
- for (let i = 0; i < targetOffset && i < node.childNodes.length; i++) {
48
- total += nodeTextLength(node.childNodes[i]);
87
+ text += (node.textContent || '').substring(0, targetOffset);
88
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
89
+ const children = Array.from(node.childNodes);
90
+ let hasContent = false;
91
+ for (let i = 0; i < Math.min(targetOffset, children.length); i++) {
92
+ const child = children[i];
93
+ if (
94
+ child.nodeType === Node.ELEMENT_NODE &&
95
+ isBlockElement(child) &&
96
+ hasContent
97
+ ) {
98
+ text += '\n';
99
+ }
100
+ const piece = textOfNode(child);
101
+ text += piece;
102
+ if (piece.length > 0) hasContent = true;
49
103
  }
50
104
  }
51
- return true; // found
105
+ stopped = true;
106
+ return;
52
107
  }
53
108
 
54
109
  if (node.nodeType === Node.TEXT_NODE) {
55
- total += node.textContent.length;
56
- return false;
110
+ text += node.textContent || '';
111
+ return;
57
112
  }
58
- // Ignore browser-added <br> artifacts
59
113
  if (node.nodeName === 'BR') {
60
- return false;
114
+ if (!isSentinelBr(node)) text += '\n';
115
+ return;
116
+ }
117
+ if (node.nodeType !== Node.ELEMENT_NODE) {
118
+ return;
61
119
  }
62
120
 
121
+ let hasContent = false;
63
122
  for (const child of Array.from(node.childNodes)) {
64
- if (walk(child)) return true;
123
+ if (stopped) break;
124
+ if (
125
+ child.nodeType === Node.ELEMENT_NODE &&
126
+ isBlockElement(child) &&
127
+ hasContent
128
+ ) {
129
+ text += '\n';
130
+ }
131
+ const before = text.length;
132
+ walk(child);
133
+ if (stopped) break;
134
+ if (text.length > before) hasContent = true;
65
135
  }
66
- return false;
67
136
  };
68
137
 
69
138
  walk(root);
70
- return total;
139
+ return text.length;
71
140
  }
72
141
 
73
142
  /** Converts a plain-text offset to a DOM position (node + offset). */
@@ -76,49 +145,103 @@ function textOffsetToDomPosition(
76
145
  targetOffset: number
77
146
  ): { node: Node; offset: number } | null {
78
147
  let remaining = targetOffset;
148
+ // Track the last text node passed through so we can fall back to its end
149
+ // when the offset sits past all text content.
150
+ let lastTextNode: Node | null = null;
151
+ let lastTextOffset = 0;
152
+ let result: { node: Node; offset: number } | null = null;
153
+
154
+ const walk = (node: Node, parent: Node | null): void => {
155
+ if (result !== null) return;
79
156
 
80
- const walk = (node: Node): { node: Node; offset: number } | null => {
81
157
  if (node.nodeType === Node.TEXT_NODE) {
82
- if (remaining <= node.textContent.length) {
83
- return { node, offset: remaining };
158
+ const len = node.textContent.length;
159
+ // Use strict "<" so positions at the boundary between two text nodes
160
+ // resolve to the START of the next node rather than the END of the
161
+ // previous one. Firefox doesn't paint the caret reliably when it lands
162
+ // at the end of an inline span whose text is just "\n", but happily
163
+ // renders it at the start of the following node.
164
+ if (remaining < len) {
165
+ result = { node, offset: remaining };
166
+ return;
84
167
  }
85
- remaining -= node.textContent.length;
86
- return null;
168
+ lastTextNode = node;
169
+ lastTextOffset = len;
170
+ remaining -= len;
171
+ return;
87
172
  }
88
- // Ignore browser-added <br> artifacts
173
+
89
174
  if (node.nodeName === 'BR') {
90
- return null;
175
+ if (isSentinelBr(node)) return;
176
+ if (remaining === 0 && parent) {
177
+ const idx = Array.from(parent.childNodes).indexOf(node as ChildNode);
178
+ if (idx >= 0) {
179
+ result = { node: parent, offset: idx };
180
+ return;
181
+ }
182
+ }
183
+ remaining -= 1;
184
+ return;
185
+ }
186
+
187
+ if (node.nodeType !== Node.ELEMENT_NODE) {
188
+ return;
91
189
  }
92
190
 
191
+ let hasContent = false;
93
192
  for (const child of Array.from(node.childNodes)) {
94
- const result = walk(child);
95
- if (result) return result;
193
+ if (result !== null) return;
194
+ if (
195
+ child.nodeType === Node.ELEMENT_NODE &&
196
+ isBlockElement(child) &&
197
+ hasContent
198
+ ) {
199
+ if (remaining === 0) {
200
+ const idx = Array.from(node.childNodes).indexOf(child as ChildNode);
201
+ if (idx >= 0) {
202
+ result = { node, offset: idx };
203
+ return;
204
+ }
205
+ }
206
+ remaining -= 1;
207
+ }
208
+ const before = remaining;
209
+ walk(child, node);
210
+ if (result !== null) return;
211
+ if (remaining < before) hasContent = true;
96
212
  }
97
- return null;
98
213
  };
99
214
 
100
- return walk(root);
215
+ walk(root, null);
216
+ if (result) return result;
217
+
218
+ if (remaining === 0) {
219
+ // Past all content. If the editor ends in a <br>, position relative to it
220
+ // so the caret renders on the empty trailing line in Firefox.
221
+ const lastChild = root.lastChild;
222
+ if (lastChild && lastChild.nodeName === 'BR') {
223
+ // Sentinel: render before it. Non-sentinel (transient post-yank state):
224
+ // render after it so the caret sits on the empty line the BR produces.
225
+ const offset = isSentinelBr(lastChild)
226
+ ? root.childNodes.length - 1
227
+ : root.childNodes.length;
228
+ return { node: root, offset };
229
+ }
230
+ if (lastTextNode) {
231
+ return { node: lastTextNode, offset: lastTextOffset };
232
+ }
233
+ }
234
+ return null;
101
235
  }
102
236
 
103
237
  /**
104
- * Extracts plain text from the contenteditable DOM by walking our span structure.
105
- * Ignores browser-added <br> artifacts. Our newlines are \n chars inside spans.
238
+ * Extracts plain text from the contenteditable DOM. Translates non-sentinel
239
+ * <br> elements to "\n" and inserts a "\n" before block-level (DIV/P) children
240
+ * that follow other content, so yank/paste-inserted DOM structures round-trip
241
+ * through handleInput correctly.
106
242
  */
107
243
  export function getTextFromEditableDiv(element: HTMLElement): string {
108
- let text = '';
109
- for (const child of Array.from(element.childNodes)) {
110
- if (child.nodeType === Node.TEXT_NODE) {
111
- text += child.textContent;
112
- } else if (child.nodeType === Node.ELEMENT_NODE) {
113
- // Skip browser-added <br> artifacts
114
- if (child.nodeName === 'BR') {
115
- continue;
116
- }
117
- // Recurse into spans and other elements
118
- text += getTextFromEditableDiv(child as HTMLElement);
119
- }
120
- }
121
- return text;
244
+ return textOfChildren(element);
122
245
  }
123
246
 
124
247
  /** Gets the caret (selection start) as a plain-text offset. */
@@ -171,3 +294,4 @@ export function setCaretRange(
171
294
  selection.removeAllRanges();
172
295
  selection.addRange(range);
173
296
  }
297
+
@@ -9,6 +9,7 @@ import { fetchResults } from '../utils';
9
9
  import { FLOW_SPEC_VERSION } from '../store/AppState';
10
10
  import {
11
11
  labelsFor,
12
+ normalizeChanges,
12
13
  RevisionChanges,
13
14
  summarizeChanges
14
15
  } from './revision-summary';
@@ -151,7 +152,7 @@ export class RevisionsWindow extends RapidElement {
151
152
  value=${rev.created_on}
152
153
  display="duration"
153
154
  ></temba-date>
154
- · ${rev.user.name || rev.user.username}
155
+ · ${this.renderUser(rev.user)}
155
156
  </div>
156
157
  ${isCurrent
157
158
  ? html`<div
@@ -192,6 +193,13 @@ export class RevisionsWindow extends RapidElement {
192
193
 
193
194
  // --- Private ---
194
195
 
196
+ private renderUser(user: Revision['user']): TemplateResult | string {
197
+ if (user?.email === 'system') {
198
+ return html`<em>System update</em>`;
199
+ }
200
+ return user?.name || user?.username || '';
201
+ }
202
+
195
203
  private async fetchRevisions() {
196
204
  const requestId = ++this.fetchRequestId;
197
205
  this.isLoading = true;
@@ -217,19 +225,36 @@ export class RevisionsWindow extends RapidElement {
217
225
  // the window. The merged revision is capped at three distinct displayed
218
226
  // labels — once a fourth would be introduced we break out into a new row.
219
227
  private collapseRevisions(revisions: Revision[]): Revision[] {
228
+ // Normalize at the boundary so the rest of the logic reasons about real
229
+ // edits only. After this step, `changes === null` is the single signal
230
+ // for "no-op" — used both for the author-barrier bypass and for keeping
231
+ // housekeeping tags out of the merged tag set and label cap.
232
+ const cleaned = revisions.map((r) => ({
233
+ ...r,
234
+ changes: normalizeChanges(r.changes)
235
+ }));
220
236
  // The API returns newest-first today; sort defensively so the head/window
221
237
  // logic stays correct if that ever changes.
222
- const sorted = [...revisions].sort(
238
+ const sorted = [...cleaned].sort(
223
239
  (a, b) =>
224
240
  new Date(b.created_on).getTime() - new Date(a.created_on).getTime()
225
241
  );
226
242
  const result: Revision[] = [];
227
243
  let group: Revision[] = [];
228
244
  let groupLabels = new Set<string>();
245
+ let groupHasRealChange = false;
229
246
 
230
247
  const flush = () => {
231
248
  if (group.length === 0) return;
232
249
  const head = group[0];
250
+ // Pick the user from the most recent real-change revision in the
251
+ // group. No-op authors (typically the system, doing spec bumps)
252
+ // shouldn't appear as the editor when a real user's edit was
253
+ // absorbed into the row — that would mislabel the change as
254
+ // "System update" even though a real person did the work. Fall back
255
+ // to the head if every revision was a no-op.
256
+ const realChange = group.find((r) => r.changes);
257
+ const displayUser = realChange?.user ?? head.user;
233
258
  const tagSet = new Set<string>();
234
259
  let anyKnown = false;
235
260
  for (const r of group) {
@@ -240,41 +265,62 @@ export class RevisionsWindow extends RapidElement {
240
265
  }
241
266
  result.push({
242
267
  ...head,
268
+ user: displayUser,
243
269
  changes: anyKnown ? { tags: Array.from(tagSet) } : null
244
270
  });
245
271
  group = [];
246
272
  groupLabels = new Set();
273
+ groupHasRealChange = false;
247
274
  };
248
275
 
249
276
  for (const rev of sorted) {
250
277
  if (group.length === 0) {
251
278
  group.push(rev);
252
279
  groupLabels = labelsFor(rev.changes);
280
+ groupHasRealChange = !!rev.changes;
253
281
  continue;
254
282
  }
255
283
  const head = group[0];
256
284
  const headTime = new Date(head.created_on).getTime();
257
285
  const revTime = new Date(rev.created_on).getTime();
258
- const withinWindow = headTime - revTime < GROUP_WINDOW_MS;
259
286
  // Compare on whichever identifier the server provides — real data
260
287
  // arrives with `email`, while test fixtures use `username`. Falling
261
288
  // back through the chain keeps both shapes working.
262
289
  const headId = head.user?.email ?? head.user?.username;
263
290
  const revId = rev.user?.email ?? rev.user?.username;
264
- const sameAuthor = headId === revId;
265
- const prospective = new Set([
266
- ...groupLabels,
267
- ...labelsFor(rev.changes)
268
- ]);
269
- const fitsLabelCap = prospective.size <= MAX_GROUP_LABELS;
291
+ // Two conditions bypass the time/author barriers:
292
+ // 1. The incoming rev is itself a no-op — it carries no editorial
293
+ // intent and should disappear into whichever group it neighbors.
294
+ // 2. The group hasn't accumulated a real change yet — we never want
295
+ // to surface a row showing "nothing changed", so a no-op-only
296
+ // chain reaches forward to absorb the first real edit even if
297
+ // that edit is far away in time or by a different author.
298
+ const isNoOp = !rev.changes;
299
+ const bypassBarriers = isNoOp || !groupHasRealChange;
300
+ const withinWindow =
301
+ bypassBarriers || headTime - revTime < GROUP_WINDOW_MS;
302
+ const sameAuthor = bypassBarriers || headId === revId;
303
+ const prospective = new Set([...groupLabels, ...labelsFor(rev.changes)]);
304
+ // The label cap is meaningful only when adding a real change to a
305
+ // group that already has one. A no-op contributes zero labels by
306
+ // construction, so it never trips the cap; and a no-op-only chain
307
+ // reaching forward to absorb its first real edit must ignore the cap
308
+ // too, or a sweeping edit (4+ label areas) would still strand the
309
+ // no-op group as an empty-summary row.
310
+ const fitsLabelCap =
311
+ isNoOp ||
312
+ !groupHasRealChange ||
313
+ prospective.size <= MAX_GROUP_LABELS;
270
314
 
271
315
  if (withinWindow && sameAuthor && fitsLabelCap) {
272
316
  group.push(rev);
273
317
  groupLabels = prospective;
318
+ if (!isNoOp) groupHasRealChange = true;
274
319
  } else {
275
320
  flush();
276
321
  group.push(rev);
277
322
  groupLabels = labelsFor(rev.changes);
323
+ groupHasRealChange = !!rev.changes;
278
324
  }
279
325
  }
280
326
  flush();
@@ -2,6 +2,23 @@ export interface RevisionChanges {
2
2
  tags: string[];
3
3
  }
4
4
 
5
+ // "spec" is the housekeeping tag the system attaches when it bumps a flow's
6
+ // spec version. It carries no editorial intent, so we strip it at the
7
+ // boundary — every downstream consumer (summaries, label caps, no-op
8
+ // detection) then operates on a clean tag set without needing special cases.
9
+ const NOOP_TAGS = new Set(['spec']);
10
+
11
+ // Drop tags that don't represent real edits and collapse to null when nothing
12
+ // meaningful remains. Returning null lets `isNoOpChanges` and the collapse
13
+ // logic treat empty-after-filtering and originally-null the same way.
14
+ export function normalizeChanges(
15
+ changes: RevisionChanges | null | undefined
16
+ ): RevisionChanges | null {
17
+ if (!changes) return null;
18
+ const tags = (changes.tags || []).filter((t) => !NOOP_TAGS.has(t));
19
+ return tags.length === 0 ? null : { tags };
20
+ }
21
+
5
22
  const TAG_LABELS: Record<string, { label: string; order: number }> = {
6
23
  metadata: { label: 'metadata', order: 0 },
7
24
  nodes: { label: 'nodes', order: 1 },
@@ -32,6 +49,14 @@ export function labelsFor(
32
49
  return result;
33
50
  }
34
51
 
52
+ // A revision is a no-op when, after stripping housekeeping tags, nothing
53
+ // meaningful is left. These shouldn't break up adjacent edits in the browser.
54
+ export function isNoOpChanges(
55
+ changes: RevisionChanges | null | undefined
56
+ ): boolean {
57
+ return normalizeChanges(changes) === null;
58
+ }
59
+
35
60
  export function summarizeChanges(
36
61
  changes: RevisionChanges | null | undefined
37
62
  ): string {
@@ -97,6 +97,7 @@ export class RichEditor extends FieldElement {
97
97
  overflow-y: auto;
98
98
  min-height: var(--textarea-min-height, 100px);
99
99
  resize: none;
100
+ position: relative;
100
101
  }
101
102
 
102
103
  :host(:not([textarea])) {
@@ -119,6 +120,12 @@ export class RichEditor extends FieldElement {
119
120
  content: attr(data-placeholder);
120
121
  color: var(--color-placeholder, #999);
121
122
  pointer-events: none;
123
+ /* Take the placeholder out of flow so the caret sits at offset 0
124
+ visually, not after the placeholder text (Firefox honors flow
125
+ strictly here while Chrome happens to overlay the caret). */
126
+ position: absolute;
127
+ inset: 0;
128
+ padding: inherit;
122
129
  }
123
130
 
124
131
  /* Token styles (shared) */
@@ -302,6 +309,11 @@ export class RichEditor extends FieldElement {
302
309
 
303
310
  if (this.disableCompletion) {
304
311
  div.textContent = text || '';
312
+ if (text && text.endsWith('\n')) {
313
+ const br = document.createElement('br');
314
+ br.setAttribute('data-sentinel', '');
315
+ div.appendChild(br);
316
+ }
305
317
  return;
306
318
  }
307
319
 
@@ -359,6 +371,15 @@ export class RichEditor extends FieldElement {
359
371
  // Ensure there's at least an empty text node for cursor placement
360
372
  if (!text || text === '') {
361
373
  div.appendChild(document.createTextNode(''));
374
+ } else if (text.endsWith('\n')) {
375
+ // A trailing "\n" text node is collapsed by Firefox in contenteditable,
376
+ // making the empty final line invisible. A sentinel <br> forces the
377
+ // line break to render. The data-sentinel attribute distinguishes it
378
+ // from browser-inserted <br>s (which represent real newlines and must
379
+ // be preserved by the caret/text utilities).
380
+ const br = document.createElement('br');
381
+ br.setAttribute('data-sentinel', '');
382
+ div.appendChild(br);
362
383
  }
363
384
  }
364
385