@nyaruka/temba-components 0.162.0 → 0.164.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.
@@ -20,6 +20,9 @@ export class DialogButton {
20
20
  }
21
21
 
22
22
  export class Dialog extends ResizeElement {
23
+ public static readonly UNSAVED_CHANGES_MESSAGE =
24
+ 'You have unsaved changes. Are you sure you want to discard them?';
25
+
23
26
  static get widths(): { [size: string]: string } {
24
27
  return {
25
28
  small: '400px',
@@ -273,12 +276,29 @@ export class Dialog extends ResizeElement {
273
276
  @property({ attribute: false })
274
277
  onButtonClicked: (button: Button) => void;
275
278
 
279
+ // when set, consulted instead of the built-in edit tracking to decide
280
+ // whether escape should confirm before dismissing — for owners that can
281
+ // compare actual values (and so ignore edits that were reverted)
282
+ @property({ attribute: false })
283
+ checkForChanges?: () => boolean;
284
+
276
285
  @property({ type: Number })
277
286
  originX: number | null = null;
278
287
 
279
288
  @property({ type: Number })
280
289
  originY: number | null = null;
281
290
 
291
+ // whether any input/change has bubbled from our content since opening;
292
+ // the default signal that escape would discard the user's edits
293
+ private contentEdited = false;
294
+
295
+ // elements the user has actually interacted with (pointer or key) since the
296
+ // dialog opened; components fire synthetic change events on programmatic
297
+ // value changes (initialization of preset values, async option resolution),
298
+ // so an input/change only counts as an edit when it originates from an
299
+ // element the user has touched
300
+ private interactedElements = new WeakSet<EventTarget>();
301
+
282
302
  scrollOffset: any = 0;
283
303
 
284
304
  public constructor() {
@@ -322,6 +342,8 @@ export class Dialog extends ResizeElement {
322
342
  super.updated(changes);
323
343
 
324
344
  if (changes.has('open')) {
345
+ this.contentEdited = false;
346
+ this.interactedElements = new WeakSet<EventTarget>();
325
347
  const body = document.querySelector('body');
326
348
 
327
349
  if (this.open) {
@@ -457,8 +479,33 @@ export class Dialog extends ResizeElement {
457
479
  return this.shadowRoot.querySelector(`temba-button[primary]`);
458
480
  }
459
481
 
482
+ public hasUnsavedChanges(): boolean {
483
+ return this.checkForChanges ? this.checkForChanges() : this.contentEdited;
484
+ }
485
+
486
+ private handleContentInteraction(event: Event) {
487
+ // the path covers the interacted element and its ancestors, so a change
488
+ // fired later by a containing component (e.g. a select whose inner input
489
+ // was clicked) still matches
490
+ event.composedPath().forEach((el) => this.interactedElements.add(el));
491
+ }
492
+
493
+ private handleContentEdited(event: Event) {
494
+ // only the originating element is checked (not its ancestors, which the
495
+ // paths of unrelated interactions also pass through)
496
+ if (this.interactedElements.has(event.composedPath()[0])) {
497
+ this.contentEdited = true;
498
+ }
499
+ }
500
+
460
501
  private handleKeyUp(event: KeyboardEvent) {
461
502
  if (event.key === 'Escape') {
503
+ if (
504
+ this.hasUnsavedChanges() &&
505
+ !window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
506
+ ) {
507
+ return;
508
+ }
462
509
  this.clickCancel();
463
510
  }
464
511
  }
@@ -528,11 +575,16 @@ export class Dialog extends ResizeElement {
528
575
  </div>
529
576
 
530
577
  <div class="flex">
531
- <div class="grow-top" style="${
532
- this.isMobile() ? 'flex-grow:0' : ''
533
- }"></div>
578
+ <div
579
+ class="grow-top"
580
+ style="${this.isMobile() ? 'flex-grow:0' : ''}"
581
+ ></div>
534
582
  <div
535
583
  @keyup=${this.handleKeyUp}
584
+ @keydown=${this.handleContentInteraction}
585
+ @pointerdown=${this.handleContentInteraction}
586
+ @input=${this.handleContentEdited}
587
+ @change=${this.handleContentEdited}
536
588
  style=${styleMap(dialogStyle)}
537
589
  class="dialog-container"
538
590
  >
@@ -542,28 +594,36 @@ export class Dialog extends ResizeElement {
542
594
  <temba-loading units="6" size="8"></temba-loading>
543
595
  </div>
544
596
 
545
- <div class="dialog-footer">
546
- <div class="flex-grow">
547
- <slot name="gutter"></slot>
548
- </div>
549
- ${this.buttons.map(
550
- (button: DialogButton, index) => html`
551
- <temba-button
552
- name=${button.name}
553
- ?destructive=${button.type == 'primary' && this.destructive}
554
- ?primary=${button.type == 'primary' && !this.destructive}
555
- ?secondary=${button.type == 'secondary'}
556
- ?submitting=${this.submitting && button.type == 'primary'}
557
- ?disabled=${this.disabled && !button.closes}
558
- index=${index}
559
- @click=${this.handleClick}
560
- ></temba-button>
561
- `
562
- )}
563
- </div>
564
- </div>
565
- <div class="grow-bottom"></div>
597
+ ${
598
+ // the gutter check is render-time only (not reactive) - fine
599
+ // while consumers slot gutter content declaratively up front
600
+ this.buttons.length > 0 || this.querySelector('[slot="gutter"]')
601
+ ? html`<div class="dialog-footer">
602
+ <div class="flex-grow">
603
+ <slot name="gutter"></slot>
604
+ </div>
605
+ ${this.buttons.map(
606
+ (button: DialogButton, index) => html`
607
+ <temba-button
608
+ name=${button.name}
609
+ ?destructive=${button.type == 'primary' &&
610
+ this.destructive}
611
+ ?primary=${button.type == 'primary' &&
612
+ !this.destructive}
613
+ ?secondary=${button.type == 'secondary'}
614
+ ?submitting=${this.submitting &&
615
+ button.type == 'primary'}
616
+ ?disabled=${this.disabled && !button.closes}
617
+ index=${index}
618
+ @click=${this.handleClick}
619
+ ></temba-button>
620
+ `
621
+ )}
622
+ </div>`
623
+ : null
624
+ }
566
625
  </div>
626
+ <div class="grow-bottom"></div>
567
627
  </div>
568
628
  </div>
569
629
  `;
@@ -0,0 +1,38 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { RapidElement } from '../RapidElement';
3
+ import { designTokens } from '../styles/designTokens';
4
+
5
+ /**
6
+ * A fixed-height header strip with a full-bleed rule under it, for the
7
+ * chat + cards pages (contact read, tickets). Pages can use several side
8
+ * by side (e.g. one over a list column, one over the chat) — the shared
9
+ * height and surface make them read as one continuous control.
10
+ */
11
+ export class HeaderBar extends RapidElement {
12
+ static get styles() {
13
+ return css`
14
+ ${designTokens}
15
+
16
+ :host {
17
+ flex: 0 0 auto;
18
+ display: flex;
19
+ align-items: center;
20
+ /* 52px strip plus the 1px rule */
21
+ height: 53px;
22
+ box-sizing: border-box;
23
+ padding: 0 8px;
24
+ background: var(--surface);
25
+ border-bottom: 1px solid var(--border);
26
+ }
27
+
28
+ ::slotted(*) {
29
+ flex-grow: 1;
30
+ min-width: 0;
31
+ }
32
+ `;
33
+ }
34
+
35
+ public render(): TemplateResult {
36
+ return html`<slot></slot>`;
37
+ }
38
+ }
@@ -70,6 +70,8 @@ export class PageHeader extends RapidElement {
70
70
  }
71
71
  .title {
72
72
  font-size: 15.5px;
73
+ /* slotted contact names render at the title's size */
74
+ --contact-name-font-size: 15.5px;
73
75
  font-weight: var(--w-semibold);
74
76
  color: var(--text-1);
75
77
  line-height: 1.25;
@@ -139,10 +141,6 @@ export class PageHeader extends RapidElement {
139
141
  .menu-button.primary:hover {
140
142
  background: var(--accent-700);
141
143
  }
142
- .menu-button[disabled] {
143
- opacity: 0.45;
144
- cursor: not-allowed;
145
- }
146
144
 
147
145
  /* Overflow toggle — the trailing ⋮ that opens the rest of the
148
146
  menu items. Plain icon button, matches the list's Search. */
@@ -282,7 +280,9 @@ export class PageHeader extends RapidElement {
282
280
  * origin — same payload the standalone content menu emits, so the
283
281
  * host's existing menu handling keeps working. */
284
282
  private handleItemClicked(item: ContentMenuItem, event: MouseEvent): void {
285
- if (item.disabled) return;
283
+ // note: item.disabled does NOT make the item inert — in the content
284
+ // menu contract it means the opened modal starts with its submit
285
+ // disabled (hosts pass it through to showModax)
286
286
  const el = event.currentTarget as Element;
287
287
  const rect = el?.getBoundingClientRect();
288
288
  this.fireCustomEvent(CustomEventType.Selection, {
@@ -300,7 +300,6 @@ export class PageHeader extends RapidElement {
300
300
  (button) => html`
301
301
  <div
302
302
  class="menu-button ${button.primary ? 'primary' : ''}"
303
- ?disabled=${button.disabled}
304
303
  @click=${(e: MouseEvent) => this.handleItemClicked(button, e)}
305
304
  >
306
305
  ${button.label}
@@ -10,6 +10,9 @@ export class Resizer extends ResizeElement {
10
10
  display: block;
11
11
  position: relative;
12
12
  width: var(--box-width, 200px);
13
+ /* hold the drag-clamp floor against flex compression too, so
14
+ shrinking the browser can't squeeze us below minWidth */
15
+ min-width: var(--box-min-width, 200px);
13
16
  --resizer-handle-size: 15px;
14
17
  }
15
18
 
@@ -69,6 +72,20 @@ export class Resizer extends ResizeElement {
69
72
  this.stopResize = this.stopResize.bind(this);
70
73
  }
71
74
 
75
+ protected willUpdate(
76
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
77
+ ): void {
78
+ super.willUpdate(changes);
79
+ // clamp programmatic width sets (e.g. a stored width restored on
80
+ // load) the same way drags are clamped
81
+ if (changes.has('currentWidth') && this.currentWidth != null) {
82
+ this.currentWidth = Math.min(
83
+ Math.max(this.currentWidth, this.minWidth),
84
+ this.maxWidth
85
+ );
86
+ }
87
+ }
88
+
72
89
  protected updated(
73
90
  _changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
74
91
  ): void {
@@ -76,6 +93,9 @@ export class Resizer extends ResizeElement {
76
93
  if (_changedProperties.has('currentWidth')) {
77
94
  this.style.setProperty('--box-width', `${this.currentWidth}px`);
78
95
  }
96
+ if (_changedProperties.has('minWidth')) {
97
+ this.style.setProperty('--box-min-width', `${this.minWidth}px`);
98
+ }
79
99
  }
80
100
 
81
101
  public setWidth(width: number) {
@@ -0,0 +1,144 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { ContentList, ContentListColumn } from './ContentList';
3
+ import { Icon } from '../Icons';
4
+ import { Campaign, CustomEventType, ObjectReference } from '../interfaces';
5
+
6
+ /**
7
+ * Campaign CRUDL list — drop-in replacement for the rapidpro
8
+ * `campaigns/campaign_list.html` table. Every row leads with the
9
+ * campaign clock icon (from the styleguide's campaign list), then
10
+ * name, the group pill the campaign schedules against, contact /
11
+ * event counts, and the last-updated date.
12
+ */
13
+ export class CampaignList extends ContentList<Campaign> {
14
+ static get styles() {
15
+ return css`
16
+ ${ContentList.styles}
17
+ .campaign-name {
18
+ color: inherit;
19
+ font-weight: var(--w-medium);
20
+ overflow: hidden;
21
+ text-overflow: ellipsis;
22
+ white-space: nowrap;
23
+ }
24
+ .num {
25
+ font-variant-numeric: tabular-nums;
26
+ color: inherit;
27
+ }
28
+ `;
29
+ }
30
+
31
+ constructor() {
32
+ super();
33
+ this.valueKey = 'uuid';
34
+ this.emptyMessage = 'No campaigns';
35
+ this.searchPlaceholder = 'Search campaigns';
36
+ this.columns = [
37
+ {
38
+ key: 'name',
39
+ label: 'Name',
40
+ sortable: true,
41
+ minWidth: '160px',
42
+ maxWidth: '280px',
43
+ pinned: true
44
+ },
45
+ {
46
+ key: 'group',
47
+ label: 'Group',
48
+ minWidth: '120px',
49
+ maxWidth: '220px'
50
+ },
51
+ {
52
+ key: 'contacts',
53
+ label: 'Contacts',
54
+ sortable: true,
55
+ minWidth: '72px',
56
+ align: 'right'
57
+ },
58
+ {
59
+ key: 'events',
60
+ label: 'Events',
61
+ sortable: true,
62
+ minWidth: '64px',
63
+ align: 'right'
64
+ },
65
+ {
66
+ key: 'modified_on',
67
+ label: 'Updated',
68
+ sortable: true,
69
+ minWidth: '96px',
70
+ maxWidth: '150px',
71
+ align: 'right'
72
+ }
73
+ ];
74
+ this.bulkActions = [
75
+ { key: 'archive', label: 'Archive', icon: Icon.archive }
76
+ ];
77
+ }
78
+
79
+ protected getRowIcon(_item: Campaign): string | null {
80
+ return Icon.campaign;
81
+ }
82
+
83
+ protected getRowHref(item: Campaign): string | null {
84
+ return item?.uuid ? `/campaign/read/${item.uuid}/` : null;
85
+ }
86
+
87
+ /** Clicking the group pill opens that group's contact list rather
88
+ * than falling through to the row click (which opens the campaign). */
89
+ private handleGroupClick(group: ObjectReference, event: MouseEvent): void {
90
+ // Stop the click from bubbling to the row's navigation handler.
91
+ event.stopPropagation();
92
+ if (!group?.uuid) return;
93
+ const href = `/contact/group/${group.uuid}/`;
94
+ // Guard the JSON-driven href against open-redirect, same as the
95
+ // row-click path in ContentList.handleRowClick.
96
+ if (!this.isSafeHref(href)) return;
97
+ // Meta/ctrl-click opens a new tab, matching ordinary links and the
98
+ // row-click behavior.
99
+ if (event.metaKey || event.ctrlKey) {
100
+ window.open(href, '_blank');
101
+ return;
102
+ }
103
+ this.fireCustomEvent(CustomEventType.Redirected, { url: href });
104
+ }
105
+
106
+ protected renderCell(
107
+ item: Campaign,
108
+ column: ContentListColumn
109
+ ): TemplateResult | string {
110
+ switch (column.key) {
111
+ case 'name':
112
+ return html`<span class="campaign-name" title=${item.name || ''}
113
+ >${item.name || ''}</span
114
+ >`;
115
+ case 'group':
116
+ return item.group
117
+ ? html`<temba-label
118
+ type="group"
119
+ icon=${Icon.group}
120
+ clickable
121
+ @click=${(e: MouseEvent) => this.handleGroupClick(item.group, e)}
122
+ >${item.group.name}</temba-label
123
+ >`
124
+ : '';
125
+ case 'events':
126
+ return html`<span class="num"
127
+ >${(item.events ?? 0).toLocaleString()}</span
128
+ >`;
129
+ case 'contacts':
130
+ return html`<span class="num"
131
+ >${(item.contacts ?? 0).toLocaleString()}</span
132
+ >`;
133
+ case 'modified_on':
134
+ return item.modified_on
135
+ ? html`<temba-date
136
+ value=${item.modified_on}
137
+ display="timedate"
138
+ ></temba-date>`
139
+ : '';
140
+ default:
141
+ return super.renderCell(item, column);
142
+ }
143
+ }
144
+ }
@@ -284,7 +284,9 @@ export class ContactList extends ContentList<Contact> {
284
284
  if (i.urn) return i.urn;
285
285
  if (Array.isArray(i.urns) && i.urns.length > 0) {
286
286
  const u = i.urns[0];
287
- return typeof u === 'string' ? u.split(':')[1] || u : u?.display || '';
287
+ return typeof u === 'string'
288
+ ? u.split(':')[1] || u
289
+ : u?.display || u?.path || '';
288
290
  }
289
291
  return '';
290
292
  }
@@ -14,23 +14,38 @@ export class FlowList extends ContentList<Flow> {
14
14
  static get styles() {
15
15
  return css`
16
16
  ${ContentList.styles}
17
+ /* The name cell is a flex row (name text, issue icon, label
18
+ chips) that fills the grow column. When space runs out the
19
+ text spans ellipsize — the flex items shrink instead of the
20
+ cell hard-clipping them mid-glyph, which is what happens to
21
+ an inline-flex atomic inline under .cell-inner's ellipsis. */
17
22
  .flow-name {
18
23
  color: inherit;
19
24
  font-weight: var(--w-medium);
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 6px;
28
+ min-width: 0;
29
+ }
30
+ .flow-name .name {
20
31
  overflow: hidden;
21
32
  text-overflow: ellipsis;
22
33
  white-space: nowrap;
23
- display: inline-flex;
24
- align-items: center;
25
- gap: 6px;
26
34
  }
27
35
  .flow-labels {
28
- display: inline-flex;
36
+ display: flex;
29
37
  align-items: center;
30
38
  gap: 4px;
31
- margin-left: 4px;
39
+ min-width: 0;
40
+ }
41
+ /* Chips shrink with the row and ellipsize internally (the
42
+ label's own slot handles that) rather than being clipped
43
+ mid-chip at the column edge. */
44
+ .flow-labels temba-label {
45
+ min-width: 0;
32
46
  }
33
47
  .issue-icon {
48
+ flex: none;
34
49
  --icon-color: var(--warning);
35
50
  }
36
51
  .num {
@@ -84,13 +99,20 @@ export class FlowList extends ContentList<Flow> {
84
99
  this.valueKey = 'uuid';
85
100
  this.emptyMessage = 'No flows';
86
101
  this.searchPlaceholder = 'Search flows';
102
+ // The name column is a `grow` column — it pools the table's
103
+ // leftover width so long names (and their label chips) get all
104
+ // available space before truncating, instead of clipping against
105
+ // a fixed cap while slack sits in the spacer. minTableWidth arms
106
+ // a horizontal scroll once the container is too narrow to keep
107
+ // the columns usable, rather than clipping anything.
108
+ this.minTableWidth = '640px';
87
109
  this.columns = [
88
110
  {
89
111
  key: 'name',
90
112
  label: 'Name',
91
113
  sortable: true,
92
114
  minWidth: '160px',
93
- maxWidth: '280px',
115
+ grow: true,
94
116
  pinned: true
95
117
  },
96
118
  {
@@ -174,7 +196,9 @@ export class FlowList extends ContentList<Flow> {
174
196
  case 'name': {
175
197
  const labels = item.labels || [];
176
198
  return html`<span class="flow-name"
177
- >${item.name || ''}
199
+ ><span class="name" title="${item.name || ''}"
200
+ >${item.name || ''}</span
201
+ >
178
202
  ${item.has_issues
179
203
  ? html`<temba-icon
180
204
  class="issue-icon"
@@ -2,6 +2,7 @@ import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
4
  import { Msg } from '../interfaces';
5
+ import { attachmentAsString } from '../utils';
5
6
 
6
7
  /**
7
8
  * Message CRUDL list — drop-in replacement for the rapidpro
@@ -14,9 +15,9 @@ export class MsgList extends ContentList<Msg> {
14
15
  static get styles() {
15
16
  return css`
16
17
  ${ContentList.styles}
17
- /* The message cell holds the body text with its attachment
18
- thumbnails right after it, and the flow / label pills pushed
19
- to the trailing edge. The text sizes to content and
18
+ /* The message cell holds the body text on the leading edge, with
19
+ its attachment thumbnails and the flow / label pills grouped
20
+ and pushed to the trailing edge. The text sizes to content and
20
21
  ellipsizes when squeezed — resolved per row, so a busy row
21
22
  doesn't widen a column for all of them. */
22
23
  .msg-cell {
@@ -34,7 +35,7 @@ export class MsgList extends ContentList<Msg> {
34
35
  text-overflow: ellipsis;
35
36
  white-space: nowrap;
36
37
  }
37
- /* Attachment thumbnails sit immediately after the text. */
38
+ /* Attachment thumbnails, immediately after the message text. */
38
39
  .msg-attachments {
39
40
  flex: 0 0 auto;
40
41
  display: flex;
@@ -44,10 +45,10 @@ export class MsgList extends ContentList<Msg> {
44
45
  /* Flow + label pills, pushed to the trailing edge of the cell. */
45
46
  .cell-pills {
46
47
  flex: 0 0 auto;
48
+ margin-left: auto;
47
49
  display: flex;
48
50
  align-items: center;
49
51
  gap: 6px;
50
- margin-left: auto;
51
52
  }
52
53
  /* Attachment thumbnails are sized well below the 44px row so
53
54
  they never grow its height — a small square preview rather
@@ -160,13 +161,18 @@ export class MsgList extends ContentList<Msg> {
160
161
  }
161
162
  }
162
163
 
163
- /** The message cell — body text, its attachment thumbnails, then
164
- * the trailing flow / label pills. Each piece sizes to content, so
165
- * the split is resolved independently for every row. */
164
+ /** The message cell — body text on the leading edge with its
165
+ * attachment thumbnails immediately after it, and the flow / label
166
+ * pills pushed to the trailing edge. Each piece sizes to content, so
167
+ * the split is resolved independently for every row. A message with
168
+ * no text renders no text span at all — otherwise its min-width
169
+ * would strand an attachment-only row's thumbnails away from the
170
+ * leading edge. */
166
171
  private renderMessageCell(item: Msg): TemplateResult {
172
+ const text = this.renderMessageText(item);
167
173
  return html`
168
174
  <div class="msg-cell">
169
- <span class="msg-text">${this.renderMessageText(item)}</span>
175
+ ${text ? html`<span class="msg-text">${text}</span>` : ''}
170
176
  ${this.renderAttachments(item)}${this.renderPills(item)}
171
177
  </div>
172
178
  `;
@@ -208,8 +214,8 @@ export class MsgList extends ContentList<Msg> {
208
214
  return item.text || '';
209
215
  }
210
216
 
211
- /** Attachment thumbnails for a row, sitting immediately after the
212
- * message text, or '' when the row carries none. */
217
+ /** Attachment thumbnails for a row, immediately after the message
218
+ * text, or '' when the row carries none. */
213
219
  private renderAttachments(item: Msg): TemplateResult | string {
214
220
  const attachments = item.attachments || [];
215
221
  if (!attachments.length) return '';
@@ -219,7 +225,7 @@ export class MsgList extends ContentList<Msg> {
219
225
  (a) => html`
220
226
  <temba-thumbnail
221
227
  class="msg-thumb"
222
- attachment=${a}
228
+ attachment=${attachmentAsString(a)}
223
229
  ></temba-thumbnail>
224
230
  `
225
231
  )}
@@ -111,9 +111,11 @@ export class SortableList extends RapidElement {
111
111
  }
112
112
 
113
113
  private getSortableElements(): Element[] {
114
+ // flatten so wrapper components can forward their host's children
115
+ // through a nested <slot>
114
116
  const eles = this.shadowRoot
115
117
  .querySelector('slot')
116
- .assignedElements()
118
+ .assignedElements({ flatten: true })
117
119
  .filter(
118
120
  (ele) =>
119
121
  ele.classList.contains('sortable') &&
@@ -539,11 +541,38 @@ export class SortableList extends RapidElement {
539
541
  private beginDrag(
540
542
  target: HTMLElement,
541
543
  clientX: number,
542
- clientY: number
544
+ clientY: number,
545
+ path: EventTarget[] = []
543
546
  ): boolean {
544
- // if we have a drag handle, only allow dragging from that element
547
+ // if we have a drag handle, only allow dragging from that element. The
548
+ // handle may live inside a sortable child's shadow DOM (where event
549
+ // retargeting hides it from event.target), so walk the composed path up
550
+ // to the first .sortable host looking for it — and require that host to
551
+ // actually be one of ours, so a same-named class elsewhere in the path
552
+ // can't start a drag.
545
553
  if (this.dragHandle) {
546
- if (!target.classList.contains(this.dragHandle)) {
554
+ const searchPath = path.length > 0 ? path : [target];
555
+ let onHandle = false;
556
+ let handleHost: Element = null;
557
+ for (const hop of searchPath) {
558
+ // shadow roots appear as non-Element hops mid-path (e.g. an icon
559
+ // inside the handle) — skip them rather than giving up
560
+ if (!(hop instanceof Element)) {
561
+ continue;
562
+ }
563
+ if (!onHandle && hop.classList.contains(this.dragHandle)) {
564
+ onHandle = true;
565
+ }
566
+ if (hop.classList.contains('sortable')) {
567
+ handleHost = onHandle ? hop : null;
568
+ break;
569
+ }
570
+ }
571
+ if (
572
+ !onHandle ||
573
+ !handleHost ||
574
+ !this.getSortableElements().includes(handleHost)
575
+ ) {
547
576
  return false;
548
577
  }
549
578
  }
@@ -572,7 +601,12 @@ export class SortableList extends RapidElement {
572
601
 
573
602
  private handleMouseDown(event: MouseEvent) {
574
603
  if (
575
- this.beginDrag(event.target as HTMLElement, event.clientX, event.clientY)
604
+ this.beginDrag(
605
+ event.target as HTMLElement,
606
+ event.clientX,
607
+ event.clientY,
608
+ event.composedPath()
609
+ )
576
610
  ) {
577
611
  event.preventDefault();
578
612
  event.stopPropagation();
@@ -586,7 +620,12 @@ export class SortableList extends RapidElement {
586
620
  const touch = event.touches[0];
587
621
  if (!touch) return;
588
622
  if (
589
- this.beginDrag(event.target as HTMLElement, touch.clientX, touch.clientY)
623
+ this.beginDrag(
624
+ event.target as HTMLElement,
625
+ touch.clientX,
626
+ touch.clientY,
627
+ event.composedPath()
628
+ )
590
629
  ) {
591
630
  event.stopPropagation();
592
631
  document.addEventListener('touchmove', this.handleTouchMove, {