@duskmoon-dev/el-cascader 0.5.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.
@@ -0,0 +1,1366 @@
1
+ /**
2
+ * @duskmoon-dev/el-cascader
3
+ *
4
+ * A multi-panel cascading selection component following Ant Design patterns.
5
+ */
6
+
7
+ import { BaseElement, css } from '@duskmoon-dev/el-core';
8
+ import type { Size, ValidationState } from '@duskmoon-dev/el-core';
9
+
10
+ // Icons with explicit dimensions for proper rendering
11
+ const chevronDownIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>`;
12
+ const chevronRightIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>`;
13
+ const checkIcon = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>`;
14
+ const closeIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
15
+ const searchIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`;
16
+ const loadingIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`;
17
+
18
+ /**
19
+ * Cascader option structure
20
+ */
21
+ export interface CascaderOption {
22
+ value: string;
23
+ label: string;
24
+ disabled?: boolean;
25
+ children?: CascaderOption[];
26
+ leaf?: boolean;
27
+ loading?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Load data function type for async loading
32
+ */
33
+ export type LoadDataFn = (option: CascaderOption) => Promise<CascaderOption[]>;
34
+
35
+ /**
36
+ * Event details
37
+ */
38
+ export interface CascaderChangeEventDetail {
39
+ value: string;
40
+ selectedOptions: CascaderOption[];
41
+ path: string[];
42
+ }
43
+
44
+ export interface CascaderExpandEventDetail {
45
+ option: CascaderOption;
46
+ level: number;
47
+ }
48
+
49
+ export interface CascaderSearchEventDetail {
50
+ searchValue: string;
51
+ }
52
+
53
+ /**
54
+ * Search result with full path
55
+ */
56
+ interface SearchResult {
57
+ path: CascaderOption[];
58
+ pathLabels: string[];
59
+ pathValues: string[];
60
+ }
61
+
62
+ // Styles
63
+ const styles = css`
64
+ :host {
65
+ display: inline-block;
66
+ width: 100%;
67
+ }
68
+
69
+ .cascader {
70
+ position: relative;
71
+ width: 100%;
72
+ }
73
+
74
+ /* Trigger Button */
75
+ .cascader-trigger {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 0.5rem;
79
+ width: 100%;
80
+ min-height: 2.75rem;
81
+ padding: 0.5rem 0.75rem;
82
+ font-size: var(--font-size-md, 1rem);
83
+ line-height: 1.5;
84
+ color: var(--color-on-surface);
85
+ background-color: var(--color-surface);
86
+ border: 1px solid var(--color-outline);
87
+ border-radius: var(--radius-md, 0.5rem);
88
+ cursor: pointer;
89
+ transition: border-color 150ms ease, box-shadow 150ms ease;
90
+ }
91
+
92
+ .cascader-trigger:hover:not(:disabled) {
93
+ border-color: var(--color-on-surface-variant);
94
+ }
95
+
96
+ .cascader-trigger:focus {
97
+ outline: none;
98
+ border-color: var(--color-primary);
99
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-primary) 15%, transparent);
100
+ }
101
+
102
+ .cascader-trigger:disabled {
103
+ cursor: not-allowed;
104
+ opacity: 0.5;
105
+ background-color: var(--color-surface-container);
106
+ }
107
+
108
+ /* Value Display */
109
+ .cascader-value {
110
+ flex: 1;
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ white-space: nowrap;
114
+ text-align: left;
115
+ }
116
+
117
+ .cascader-placeholder {
118
+ color: var(--color-on-surface-variant);
119
+ opacity: 0.7;
120
+ }
121
+
122
+ /* Tags Container (for multiple mode) */
123
+ .cascader-tags {
124
+ display: flex;
125
+ flex-wrap: wrap;
126
+ gap: 0.25rem;
127
+ flex: 1;
128
+ min-width: 0;
129
+ }
130
+
131
+ .cascader-tag {
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 0.25rem;
135
+ max-width: 100%;
136
+ padding: 0.125rem 0.25rem 0.125rem 0.5rem;
137
+ font-size: var(--font-size-sm, 0.875rem);
138
+ line-height: 1.25rem;
139
+ background-color: var(--color-surface-container-high, #e8e8e8);
140
+ color: var(--color-on-surface);
141
+ border-radius: 1rem;
142
+ }
143
+
144
+ .cascader-tag-text {
145
+ overflow: hidden;
146
+ text-overflow: ellipsis;
147
+ white-space: nowrap;
148
+ }
149
+
150
+ .cascader-tag-remove {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ width: 16px;
155
+ height: 16px;
156
+ padding: 0;
157
+ color: inherit;
158
+ background-color: transparent;
159
+ border-radius: 50%;
160
+ cursor: pointer;
161
+ opacity: 0.7;
162
+ transition: opacity 150ms ease, background-color 150ms ease;
163
+ }
164
+
165
+ .cascader-tag-remove svg {
166
+ width: 10px;
167
+ height: 10px;
168
+ display: block;
169
+ }
170
+
171
+ .cascader-tag-remove:hover {
172
+ opacity: 1;
173
+ background-color: color-mix(in oklch, currentColor 15%, transparent);
174
+ }
175
+
176
+ .cascader-tag-overflow {
177
+ padding: 0.125rem 0.5rem;
178
+ background-color: var(--color-surface-container);
179
+ color: var(--color-on-surface-variant);
180
+ }
181
+
182
+ /* Icons */
183
+ .cascader-arrow {
184
+ display: inline-flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ width: 20px;
188
+ height: 20px;
189
+ flex-shrink: 0;
190
+ color: var(--color-on-surface-variant);
191
+ transition: transform 150ms ease;
192
+ }
193
+
194
+ .cascader-arrow svg {
195
+ width: 16px;
196
+ height: 16px;
197
+ display: block;
198
+ }
199
+
200
+ .cascader.open .cascader-arrow {
201
+ transform: rotate(180deg);
202
+ }
203
+
204
+ .cascader-clear {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ width: 20px;
209
+ height: 20px;
210
+ padding: 0;
211
+ color: var(--color-on-surface-variant);
212
+ background-color: transparent;
213
+ border-radius: 50%;
214
+ cursor: pointer;
215
+ flex-shrink: 0;
216
+ transition: background-color 150ms ease;
217
+ }
218
+
219
+ .cascader-clear svg {
220
+ width: 14px;
221
+ height: 14px;
222
+ display: block;
223
+ }
224
+
225
+ .cascader-clear:hover {
226
+ background-color: var(--color-surface-container-high);
227
+ }
228
+
229
+ /* Dropdown - uses Popover API (top-layer requires position: fixed) */
230
+ .cascader-dropdown {
231
+ position: fixed;
232
+ margin: 0;
233
+ padding: 0;
234
+ border: 1px solid var(--color-outline-variant);
235
+ border-radius: var(--radius-md, 0.5rem);
236
+ background-color: var(--color-surface);
237
+ box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1));
238
+ overflow: hidden;
239
+ display: none;
240
+ flex-direction: column;
241
+ z-index: 1000;
242
+ }
243
+
244
+ .cascader-dropdown:popover-open {
245
+ display: flex;
246
+ }
247
+
248
+ /* Search */
249
+ .cascader-search {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 0.5rem;
253
+ padding: 0.5rem;
254
+ border-bottom: 1px solid var(--color-outline-variant);
255
+ }
256
+
257
+ .cascader-search-icon {
258
+ display: inline-flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ width: 16px;
262
+ height: 16px;
263
+ color: var(--color-on-surface-variant);
264
+ flex-shrink: 0;
265
+ }
266
+
267
+ .cascader-search-icon svg {
268
+ width: 14px;
269
+ height: 14px;
270
+ display: block;
271
+ }
272
+
273
+ .cascader-search-input {
274
+ flex: 1;
275
+ padding: 0.375rem 0.5rem;
276
+ font-size: var(--font-size-sm, 0.875rem);
277
+ color: var(--color-on-surface);
278
+ background-color: var(--color-surface-container);
279
+ border: none;
280
+ border-radius: var(--radius-sm, 0.25rem);
281
+ outline: none;
282
+ }
283
+
284
+ .cascader-search-input:focus {
285
+ background-color: var(--color-surface-container-high);
286
+ }
287
+
288
+ .cascader-search-input::placeholder {
289
+ color: var(--color-on-surface-variant);
290
+ opacity: 0.7;
291
+ }
292
+
293
+ /* Panels Container */
294
+ .cascader-panels {
295
+ display: flex;
296
+ max-height: 18rem;
297
+ }
298
+
299
+ /* Panel */
300
+ .cascader-panel {
301
+ display: flex;
302
+ flex-direction: column;
303
+ min-width: 10rem;
304
+ max-width: 14rem;
305
+ max-height: 18rem;
306
+ overflow-y: auto;
307
+ border-right: 1px solid var(--color-outline-variant);
308
+ }
309
+
310
+ .cascader-panel:last-child {
311
+ border-right: none;
312
+ }
313
+
314
+ .cascader-panel-options {
315
+ padding: 0.25rem;
316
+ }
317
+
318
+ /* Option */
319
+ .cascader-option {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 0.5rem;
323
+ width: 100%;
324
+ padding: 0.5rem 0.75rem;
325
+ font-size: var(--font-size-sm, 0.875rem);
326
+ color: var(--color-on-surface);
327
+ background-color: transparent;
328
+ border: none;
329
+ border-radius: var(--radius-sm, 0.25rem);
330
+ cursor: pointer;
331
+ text-align: left;
332
+ transition: background-color 150ms ease;
333
+ }
334
+
335
+ .cascader-option:hover:not(.disabled) {
336
+ background-color: var(--color-surface-container);
337
+ }
338
+
339
+ .cascader-option.active {
340
+ background-color: var(--color-surface-container-high);
341
+ }
342
+
343
+ .cascader-option.selected {
344
+ background-color: var(--color-primary-container, #e8def8);
345
+ color: var(--color-on-primary-container, #1d1b20);
346
+ }
347
+
348
+ .cascader-option.disabled {
349
+ opacity: 0.5;
350
+ cursor: not-allowed;
351
+ }
352
+
353
+ .cascader-option-checkbox {
354
+ display: flex;
355
+ align-items: center;
356
+ justify-content: center;
357
+ width: 1rem;
358
+ height: 1rem;
359
+ background-color: transparent;
360
+ border: 2px solid var(--color-on-surface-variant);
361
+ border-radius: 0.125rem;
362
+ flex-shrink: 0;
363
+ }
364
+
365
+ .cascader-option.selected .cascader-option-checkbox {
366
+ background-color: var(--color-primary);
367
+ border-color: var(--color-primary);
368
+ color: var(--color-on-primary, white);
369
+ }
370
+
371
+ .cascader-option-label {
372
+ flex: 1;
373
+ overflow: hidden;
374
+ text-overflow: ellipsis;
375
+ white-space: nowrap;
376
+ }
377
+
378
+ .cascader-option-arrow {
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ color: var(--color-on-surface-variant);
383
+ flex-shrink: 0;
384
+ }
385
+
386
+ .cascader-option-loading {
387
+ display: flex;
388
+ align-items: center;
389
+ justify-content: center;
390
+ flex-shrink: 0;
391
+ }
392
+
393
+ .cascader-option-loading .spinner {
394
+ animation: spin 1s linear infinite;
395
+ }
396
+
397
+ @keyframes spin {
398
+ from { transform: rotate(0deg); }
399
+ to { transform: rotate(360deg); }
400
+ }
401
+
402
+ /* Search Results */
403
+ .cascader-search-results {
404
+ padding: 0.25rem;
405
+ max-height: 18rem;
406
+ overflow-y: auto;
407
+ }
408
+
409
+ .cascader-search-result {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 0.5rem;
413
+ width: 100%;
414
+ padding: 0.5rem 0.75rem;
415
+ font-size: var(--font-size-sm, 0.875rem);
416
+ color: var(--color-on-surface);
417
+ background-color: transparent;
418
+ border: none;
419
+ border-radius: var(--radius-sm, 0.25rem);
420
+ cursor: pointer;
421
+ text-align: left;
422
+ transition: background-color 150ms ease;
423
+ }
424
+
425
+ .cascader-search-result:hover {
426
+ background-color: var(--color-surface-container);
427
+ }
428
+
429
+ .cascader-search-result.selected {
430
+ background-color: var(--color-primary-container, #e8def8);
431
+ color: var(--color-on-primary-container, #1d1b20);
432
+ }
433
+
434
+ .cascader-search-result-path {
435
+ flex: 1;
436
+ overflow: hidden;
437
+ text-overflow: ellipsis;
438
+ white-space: nowrap;
439
+ }
440
+
441
+ .cascader-search-result-separator {
442
+ color: var(--color-on-surface-variant);
443
+ margin: 0 0.25rem;
444
+ }
445
+
446
+ /* Empty State */
447
+ .cascader-empty {
448
+ padding: 1.5rem;
449
+ text-align: center;
450
+ color: var(--color-on-surface-variant);
451
+ font-size: var(--font-size-sm, 0.875rem);
452
+ }
453
+
454
+ /* Size Variants */
455
+ :host([size="sm"]) .cascader-trigger {
456
+ min-height: 2.25rem;
457
+ padding: 0.375rem 0.5rem;
458
+ font-size: var(--font-size-sm, 0.875rem);
459
+ border-radius: var(--radius-sm, 0.375rem);
460
+ }
461
+
462
+ :host([size="lg"]) .cascader-trigger {
463
+ min-height: 3.25rem;
464
+ padding: 0.625rem 1rem;
465
+ font-size: var(--font-size-lg, 1.125rem);
466
+ border-radius: var(--radius-lg, 0.625rem);
467
+ }
468
+
469
+ /* Validation States */
470
+ :host([validation-state="invalid"]) .cascader-trigger {
471
+ border-color: var(--color-error);
472
+ }
473
+
474
+ :host([validation-state="invalid"]) .cascader-trigger:focus {
475
+ border-color: var(--color-error);
476
+ box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-error) 15%, transparent);
477
+ }
478
+
479
+ :host([validation-state="valid"]) .cascader-trigger {
480
+ border-color: var(--color-success);
481
+ }
482
+
483
+ /* Disabled State */
484
+ :host([disabled]) {
485
+ pointer-events: none;
486
+ }
487
+
488
+ :host([disabled]) .cascader-trigger {
489
+ cursor: not-allowed;
490
+ opacity: 0.5;
491
+ background-color: var(--color-surface-container);
492
+ }
493
+ `;
494
+
495
+ /**
496
+ * DuskMoon Cascader Element
497
+ *
498
+ * @element el-dm-cascader
499
+ *
500
+ * @attr {string} value - Selected path as JSON array: '["province", "city", "district"]'
501
+ * @attr {string} placeholder - Placeholder text when no selection.
502
+ * @attr {boolean} disabled - Disable the cascader.
503
+ * @attr {boolean} multiple - Enable multi-path selection.
504
+ * @attr {boolean} searchable - Enable search functionality.
505
+ * @attr {boolean} clearable - Show clear button.
506
+ * @attr {boolean} change-on-select - Emit change on each level (vs only leaf).
507
+ * @attr {string} expand-trigger - Expand trigger: 'click' (default) | 'hover'.
508
+ * @attr {string} separator - Display separator (default: ' / ').
509
+ * @attr {boolean} show-all-levels - Show full path or just leaf label.
510
+ * @attr {string} show-checked-strategy - For multiple: 'all' | 'parent' | 'child'.
511
+ * @attr {string} size - Size variant: 'sm' | 'md' | 'lg'.
512
+ * @attr {string} validation-state - Validation state: 'valid' | 'invalid'.
513
+ *
514
+ * @fires change - Fired when selection changes.
515
+ * @fires expand - Fired when panel expanded.
516
+ * @fires search - Fired when search input changes.
517
+ */
518
+ export class ElDmCascader extends BaseElement {
519
+ static properties = {
520
+ value: { type: String, reflect: true, default: '' },
521
+ placeholder: { type: String, default: 'Select...' },
522
+ disabled: { type: Boolean, reflect: true, default: false },
523
+ multiple: { type: Boolean, reflect: true, default: false },
524
+ searchable: { type: Boolean, reflect: true, default: false },
525
+ clearable: { type: Boolean, reflect: true, default: false },
526
+ changeOnSelect: { type: Boolean, reflect: true, attribute: 'change-on-select', default: false },
527
+ expandTrigger: { type: String, attribute: 'expand-trigger', default: 'click' },
528
+ separator: { type: String, default: ' / ' },
529
+ showAllLevels: { type: Boolean, reflect: true, attribute: 'show-all-levels', default: true },
530
+ showCheckedStrategy: { type: String, attribute: 'show-checked-strategy', default: 'all' },
531
+ size: { type: String, reflect: true, default: 'md' },
532
+ validationState: { type: String, reflect: true, attribute: 'validation-state' },
533
+ options: { type: String, default: '' },
534
+ };
535
+
536
+ // Declared properties
537
+ declare value: string;
538
+ declare placeholder: string;
539
+ declare disabled: boolean;
540
+ declare multiple: boolean;
541
+ declare searchable: boolean;
542
+ declare clearable: boolean;
543
+ declare changeOnSelect: boolean;
544
+ declare expandTrigger: 'click' | 'hover';
545
+ declare separator: string;
546
+ declare showAllLevels: boolean;
547
+ declare showCheckedStrategy: 'all' | 'parent' | 'child';
548
+ declare size: Size;
549
+ declare validationState: ValidationState;
550
+ declare options: string;
551
+
552
+ // Internal state
553
+ private _isOpen = false;
554
+ private _searchValue = '';
555
+ private _activePath: string[] = [];
556
+ private _selectedPaths: string[][] = [];
557
+ private _loadingKeys = new Set<string>();
558
+ private _options: CascaderOption[] = [];
559
+ private _loadDataFn: LoadDataFn | null = null;
560
+
561
+ // Bound handlers
562
+ private _handleOutsideClick = this._onOutsideClick.bind(this);
563
+ private _handleKeyDown = this._onKeyDown.bind(this);
564
+ private _handleScroll = this._onScroll.bind(this);
565
+ private _handleResize = this._onResize.bind(this);
566
+
567
+ constructor() {
568
+ super();
569
+ this.attachStyles(styles);
570
+ }
571
+
572
+ connectedCallback(): void {
573
+ super.connectedCallback();
574
+ document.addEventListener('click', this._handleOutsideClick);
575
+ document.addEventListener('keydown', this._handleKeyDown);
576
+
577
+ // Parse options from attribute (for static HTML/MDX usage)
578
+ this._parseOptionsFromAttribute();
579
+
580
+ this._parseValue();
581
+
582
+ // Set up event delegation once
583
+ this._setupEventDelegation();
584
+ }
585
+
586
+ /**
587
+ * Parse options from JSON string attribute
588
+ */
589
+ private _parseOptionsFromAttribute(): void {
590
+ if (this.options) {
591
+ try {
592
+ const parsed = JSON.parse(this.options);
593
+ if (Array.isArray(parsed)) {
594
+ this._options = parsed;
595
+ }
596
+ } catch {
597
+ // Invalid JSON, ignore
598
+ }
599
+ }
600
+ }
601
+
602
+ disconnectedCallback(): void {
603
+ super.disconnectedCallback();
604
+ document.removeEventListener('click', this._handleOutsideClick);
605
+ document.removeEventListener('keydown', this._handleKeyDown);
606
+ this._removeScrollListeners();
607
+ }
608
+
609
+ private _addScrollListeners(): void {
610
+ window.addEventListener('scroll', this._handleScroll, true);
611
+ window.addEventListener('resize', this._handleResize);
612
+ }
613
+
614
+ private _removeScrollListeners(): void {
615
+ window.removeEventListener('scroll', this._handleScroll, true);
616
+ window.removeEventListener('resize', this._handleResize);
617
+ }
618
+
619
+ private _onScroll(): void {
620
+ if (this._isOpen) {
621
+ const dropdown = this.shadowRoot?.querySelector('.cascader-dropdown') as HTMLElement;
622
+ const trigger = this.shadowRoot?.querySelector('.cascader-trigger') as HTMLElement;
623
+ if (dropdown && trigger) {
624
+ this._positionDropdown(dropdown, trigger);
625
+ }
626
+ }
627
+ }
628
+
629
+ private _onResize(): void {
630
+ if (this._isOpen) {
631
+ this._close();
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Set cascader options
637
+ */
638
+ setOptions(options: CascaderOption[]): void {
639
+ this._options = options;
640
+ this.update();
641
+ }
642
+
643
+ /**
644
+ * Set async load data function
645
+ */
646
+ setLoadData(fn: LoadDataFn): void {
647
+ this._loadDataFn = fn;
648
+ }
649
+
650
+ /**
651
+ * Parse value into selected paths
652
+ */
653
+ private _parseValue(): void {
654
+ if (!this.value) {
655
+ this._selectedPaths = [];
656
+ return;
657
+ }
658
+
659
+ try {
660
+ const parsed = JSON.parse(this.value);
661
+ if (this.multiple) {
662
+ // Multiple mode: array of paths
663
+ this._selectedPaths = Array.isArray(parsed[0]) ? parsed : [parsed];
664
+ } else {
665
+ // Single mode: single path
666
+ this._selectedPaths = Array.isArray(parsed) ? [parsed] : [];
667
+ }
668
+ } catch {
669
+ this._selectedPaths = [];
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Get panels data based on active path
675
+ */
676
+ private _getPanels(): CascaderOption[][] {
677
+ const panels: CascaderOption[][] = [this._options];
678
+
679
+ let currentOptions = this._options;
680
+ for (const value of this._activePath) {
681
+ const option = currentOptions.find((o) => o.value === value);
682
+ if (option?.children && option.children.length > 0) {
683
+ panels.push(option.children);
684
+ currentOptions = option.children;
685
+ } else {
686
+ break;
687
+ }
688
+ }
689
+
690
+ return panels;
691
+ }
692
+
693
+ /**
694
+ * Get display label for current selection
695
+ */
696
+ private _getDisplayLabel(): string {
697
+ if (this._selectedPaths.length === 0) {
698
+ return '';
699
+ }
700
+
701
+ const path = this._selectedPaths[0];
702
+ const labels = this._getPathLabels(path);
703
+
704
+ if (this.showAllLevels) {
705
+ return labels.join(this.separator);
706
+ }
707
+ return labels[labels.length - 1] || '';
708
+ }
709
+
710
+ /**
711
+ * Get labels for a path
712
+ */
713
+ private _getPathLabels(path: string[]): string[] {
714
+ const labels: string[] = [];
715
+ let currentOptions = this._options;
716
+
717
+ for (const value of path) {
718
+ const option = currentOptions.find((o) => o.value === value);
719
+ if (option) {
720
+ labels.push(option.label);
721
+ currentOptions = option.children || [];
722
+ }
723
+ }
724
+
725
+ return labels;
726
+ }
727
+
728
+ /**
729
+ * Find option by path
730
+ */
731
+ private _findOptionByPath(path: string[]): CascaderOption | undefined {
732
+ let currentOptions = this._options;
733
+ let option: CascaderOption | undefined;
734
+
735
+ for (const value of path) {
736
+ option = currentOptions.find((o) => o.value === value);
737
+ if (option?.children) {
738
+ currentOptions = option.children;
739
+ }
740
+ }
741
+
742
+ return option;
743
+ }
744
+
745
+ /**
746
+ * Check if option is leaf (no children or marked as leaf)
747
+ */
748
+ private _isLeaf(option: CascaderOption): boolean {
749
+ if (option.leaf === true) return true;
750
+ if (option.leaf === false) return false;
751
+ return !option.children || option.children.length === 0;
752
+ }
753
+
754
+ /**
755
+ * Search options recursively
756
+ */
757
+ private _searchOptions(): SearchResult[] {
758
+ const results: SearchResult[] = [];
759
+ const search = this._searchValue.toLowerCase();
760
+
761
+ const searchRecursive = (
762
+ options: CascaderOption[],
763
+ path: CascaderOption[],
764
+ pathValues: string[]
765
+ ): void => {
766
+ for (const option of options) {
767
+ const newPath = [...path, option];
768
+ const newPathValues = [...pathValues, option.value];
769
+
770
+ // Check if this option matches
771
+ if (option.label.toLowerCase().includes(search)) {
772
+ // Only add if it's a leaf or changeOnSelect is true
773
+ if (this._isLeaf(option) || this.changeOnSelect) {
774
+ results.push({
775
+ path: newPath,
776
+ pathLabels: newPath.map((o) => o.label),
777
+ pathValues: newPathValues,
778
+ });
779
+ }
780
+ }
781
+
782
+ // Search children
783
+ if (option.children) {
784
+ searchRecursive(option.children, newPath, newPathValues);
785
+ }
786
+ }
787
+ };
788
+
789
+ searchRecursive(this._options, [], []);
790
+ return results;
791
+ }
792
+
793
+ // Event handlers
794
+ private _onOutsideClick(e: MouseEvent): void {
795
+ if (!this.contains(e.target as Node)) {
796
+ this._close();
797
+ }
798
+ }
799
+
800
+ private _onKeyDown(e: KeyboardEvent): void {
801
+ if (!this._isOpen) {
802
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
803
+ if (document.activeElement === this || this.contains(document.activeElement)) {
804
+ e.preventDefault();
805
+ this._open();
806
+ }
807
+ }
808
+ return;
809
+ }
810
+
811
+ switch (e.key) {
812
+ case 'Escape':
813
+ e.preventDefault();
814
+ this._close();
815
+ break;
816
+
817
+ case 'ArrowLeft':
818
+ e.preventDefault();
819
+ if (this._activePath.length > 0) {
820
+ this._activePath = this._activePath.slice(0, -1);
821
+ this.update();
822
+ }
823
+ break;
824
+
825
+ case 'ArrowRight':
826
+ // Navigate into selected option if it has children
827
+ break;
828
+ }
829
+ }
830
+
831
+ private _open(): void {
832
+ if (this.disabled) return;
833
+ this._isOpen = true;
834
+ this._activePath = this._selectedPaths[0] ? [...this._selectedPaths[0]] : [];
835
+ this.update();
836
+
837
+ // Add scroll/resize listeners to keep dropdown positioned
838
+ this._addScrollListeners();
839
+
840
+ // Show popover and position it
841
+ requestAnimationFrame(() => {
842
+ const dropdown = this.shadowRoot?.querySelector('.cascader-dropdown') as HTMLElement;
843
+ const trigger = this.shadowRoot?.querySelector('.cascader-trigger') as HTMLElement;
844
+ if (dropdown && trigger) {
845
+ // Set initial position before showing (based on trigger only)
846
+ const triggerRect = trigger.getBoundingClientRect();
847
+ dropdown.style.top = `${triggerRect.bottom + 4}px`;
848
+ dropdown.style.left = `${triggerRect.left}px`;
849
+ dropdown.style.minWidth = `${triggerRect.width}px`;
850
+
851
+ // Show the popover
852
+ dropdown.showPopover();
853
+
854
+ // Recalculate position after layout is complete
855
+ requestAnimationFrame(() => {
856
+ this._positionDropdown(dropdown, trigger);
857
+ });
858
+ }
859
+
860
+ // Focus search input if searchable
861
+ const searchInput = this.shadowRoot?.querySelector('.cascader-search-input') as HTMLInputElement;
862
+ if (searchInput) {
863
+ searchInput.focus();
864
+ }
865
+ });
866
+ }
867
+
868
+ private _close(): void {
869
+ this._isOpen = false;
870
+ this._searchValue = '';
871
+ this._activePath = [];
872
+
873
+ // Remove scroll/resize listeners
874
+ this._removeScrollListeners();
875
+
876
+ // Hide popover
877
+ const dropdown = this.shadowRoot?.querySelector('.cascader-dropdown') as HTMLElement;
878
+ if (dropdown) {
879
+ try {
880
+ dropdown.hidePopover();
881
+ } catch {
882
+ // Ignore if already hidden
883
+ }
884
+ }
885
+
886
+ this.update();
887
+ }
888
+
889
+ private _positionDropdown(dropdown: HTMLElement, trigger: HTMLElement): void {
890
+ const triggerRect = trigger.getBoundingClientRect();
891
+ const viewportHeight = window.innerHeight;
892
+ const viewportWidth = window.innerWidth;
893
+
894
+ // Position below the trigger by default
895
+ let top = triggerRect.bottom + 4;
896
+ let left = triggerRect.left;
897
+
898
+ // Get dropdown dimensions after it's visible
899
+ const dropdownRect = dropdown.getBoundingClientRect();
900
+
901
+ // If not enough space below, position above
902
+ if (top + dropdownRect.height > viewportHeight && triggerRect.top > dropdownRect.height) {
903
+ top = triggerRect.top - dropdownRect.height - 4;
904
+ }
905
+
906
+ // Ensure dropdown doesn't go off-screen horizontally
907
+ if (left + dropdownRect.width > viewportWidth) {
908
+ left = viewportWidth - dropdownRect.width - 8;
909
+ }
910
+ if (left < 8) {
911
+ left = 8;
912
+ }
913
+
914
+ dropdown.style.top = `${top}px`;
915
+ dropdown.style.left = `${left}px`;
916
+ // Cascader may be wider than trigger due to panels
917
+ dropdown.style.minWidth = `${triggerRect.width}px`;
918
+ }
919
+
920
+ private _toggle(): void {
921
+ if (this._isOpen) {
922
+ this._close();
923
+ } else {
924
+ this._open();
925
+ }
926
+ }
927
+
928
+ private async _handleOptionClick(value: string, level: number): Promise<void> {
929
+ // Update active path
930
+ this._activePath = this._activePath.slice(0, level);
931
+ this._activePath.push(value);
932
+
933
+ const option = this._findOptionByPath(this._activePath);
934
+ if (!option) {
935
+ return;
936
+ }
937
+
938
+ // Check if we need to load children
939
+ if (this._loadDataFn && !option.children && !option.leaf) {
940
+ this._loadingKeys.add(value);
941
+ this.update();
942
+
943
+ try {
944
+ const children = await this._loadDataFn(option);
945
+ option.children = children;
946
+ } finally {
947
+ this._loadingKeys.delete(value);
948
+ }
949
+ }
950
+
951
+ // Emit expand event
952
+ this.emit<CascaderExpandEventDetail>('expand', { option, level });
953
+
954
+ // Handle selection
955
+ const isLeaf = this._isLeaf(option);
956
+ if (isLeaf || this.changeOnSelect) {
957
+ this._selectPath([...this._activePath]);
958
+
959
+ if (isLeaf && !this.multiple) {
960
+ this._close();
961
+ }
962
+ }
963
+
964
+ this.update();
965
+ }
966
+
967
+ private _handleOptionHover(value: string, level: number): void {
968
+ if (this.expandTrigger !== 'hover') return;
969
+
970
+ this._activePath = this._activePath.slice(0, level);
971
+ this._activePath.push(value);
972
+ this.update();
973
+
974
+ // Trigger async load if needed
975
+ const option = this._findOptionByPath(this._activePath);
976
+ if (option && this._loadDataFn && !option.children && !option.leaf) {
977
+ this._handleOptionClick(value, level);
978
+ }
979
+ }
980
+
981
+ private _selectPath(path: string[]): void {
982
+ if (this.multiple) {
983
+ // Toggle path in selected paths
984
+ const pathStr = JSON.stringify(path);
985
+ const index = this._selectedPaths.findIndex(
986
+ (p) => JSON.stringify(p) === pathStr
987
+ );
988
+
989
+ if (index >= 0) {
990
+ this._selectedPaths.splice(index, 1);
991
+ } else {
992
+ this._selectedPaths.push(path);
993
+ }
994
+
995
+ this.value = JSON.stringify(this._selectedPaths);
996
+ } else {
997
+ this._selectedPaths = [path];
998
+ this.value = JSON.stringify(path);
999
+ }
1000
+
1001
+ this._emitChange();
1002
+ }
1003
+
1004
+ private _selectSearchResult(result: SearchResult): void {
1005
+ this._selectPath(result.pathValues);
1006
+ if (!this.multiple) {
1007
+ this._close();
1008
+ }
1009
+ this.update();
1010
+ }
1011
+
1012
+ private _removeTag(pathIndex: number): void {
1013
+ this._selectedPaths.splice(pathIndex, 1);
1014
+ this.value = this._selectedPaths.length > 0 ? JSON.stringify(this._selectedPaths) : '';
1015
+ this._emitChange();
1016
+ this.update();
1017
+ }
1018
+
1019
+ private _handleSearch(e: Event): void {
1020
+ const input = e.target as HTMLInputElement;
1021
+ this._searchValue = input.value;
1022
+ this.emit<CascaderSearchEventDetail>('search', { searchValue: this._searchValue });
1023
+ this.update();
1024
+ }
1025
+
1026
+ private _handleClear(e: Event): void {
1027
+ e.stopPropagation();
1028
+ this.value = '';
1029
+ this._selectedPaths = [];
1030
+ this.emit('clear', {});
1031
+ this._emitChange();
1032
+ this.update();
1033
+ }
1034
+
1035
+ private _emitChange(): void {
1036
+ const selectedOptions = this._selectedPaths.map((path) =>
1037
+ this._findOptionByPath(path)
1038
+ ).filter((o): o is CascaderOption => o !== undefined);
1039
+
1040
+ this.emit<CascaderChangeEventDetail>('change', {
1041
+ value: this.value,
1042
+ selectedOptions,
1043
+ path: this._selectedPaths[0] || [],
1044
+ });
1045
+ }
1046
+
1047
+ // Rendering
1048
+ protected render(): string {
1049
+ return `
1050
+ <div class="cascader ${this._isOpen ? 'open' : ''}">
1051
+ ${this._renderTrigger()}
1052
+ ${this._renderDropdown()}
1053
+ </div>
1054
+ `;
1055
+ }
1056
+
1057
+ private _renderTrigger(): string {
1058
+ const hasValue = this._selectedPaths.length > 0;
1059
+ const showClear = this.clearable && hasValue && !this.disabled;
1060
+
1061
+ return `
1062
+ <button
1063
+ type="button"
1064
+ class="cascader-trigger"
1065
+ aria-haspopup="listbox"
1066
+ aria-expanded="${this._isOpen}"
1067
+ ${this.disabled ? 'disabled' : ''}
1068
+ data-action="toggle"
1069
+ >
1070
+ ${this.multiple && hasValue ? this._renderTags() : this._renderValue()}
1071
+ ${showClear ? `<span class="cascader-clear" role="button" tabindex="-1" data-action="clear">${closeIcon}</span>` : ''}
1072
+ <span class="cascader-arrow">${chevronDownIcon}</span>
1073
+ </button>
1074
+ `;
1075
+ }
1076
+
1077
+ private _renderValue(): string {
1078
+ const displayLabel = this._getDisplayLabel();
1079
+ if (!displayLabel) {
1080
+ return `<span class="cascader-value cascader-placeholder">${this.placeholder}</span>`;
1081
+ }
1082
+ return `<span class="cascader-value">${this._escapeHtml(displayLabel)}</span>`;
1083
+ }
1084
+
1085
+ private _renderTags(): string {
1086
+ const tagsHtml = this._selectedPaths
1087
+ .map((path, index) => {
1088
+ const labels = this._getPathLabels(path);
1089
+ const displayLabel = this.showAllLevels
1090
+ ? labels.join(this.separator)
1091
+ : labels[labels.length - 1];
1092
+
1093
+ return `
1094
+ <span class="cascader-tag">
1095
+ <span class="cascader-tag-text">${this._escapeHtml(displayLabel)}</span>
1096
+ <span class="cascader-tag-remove" role="button" tabindex="-1" data-action="remove-tag" data-index="${index}">${closeIcon}</span>
1097
+ </span>
1098
+ `;
1099
+ })
1100
+ .join('');
1101
+
1102
+ return `<div class="cascader-tags">${tagsHtml || `<span class="cascader-placeholder">${this.placeholder}</span>`}</div>`;
1103
+ }
1104
+
1105
+ private _renderDropdown(): string {
1106
+ const showSearch = this.searchable && this._searchValue;
1107
+
1108
+ return `
1109
+ <div class="cascader-dropdown" role="listbox" popover="manual">
1110
+ ${this.searchable ? this._renderSearch() : ''}
1111
+ ${showSearch ? this._renderSearchResults() : this._renderPanels()}
1112
+ </div>
1113
+ `;
1114
+ }
1115
+
1116
+ private _renderSearch(): string {
1117
+ return `
1118
+ <div class="cascader-search">
1119
+ <span class="cascader-search-icon">${searchIcon}</span>
1120
+ <input
1121
+ type="text"
1122
+ class="cascader-search-input"
1123
+ placeholder="Search..."
1124
+ value="${this._escapeHtml(this._searchValue)}"
1125
+ data-action="search"
1126
+ />
1127
+ </div>
1128
+ `;
1129
+ }
1130
+
1131
+ private _renderPanels(): string {
1132
+ const panels = this._getPanels();
1133
+
1134
+ if (panels.length === 0 || panels[0].length === 0) {
1135
+ return `<div class="cascader-empty">No options available</div>`;
1136
+ }
1137
+
1138
+ return `
1139
+ <div class="cascader-panels">
1140
+ ${panels.map((options, level) => this._renderPanel(options, level)).join('')}
1141
+ </div>
1142
+ `;
1143
+ }
1144
+
1145
+ private _renderPanel(options: CascaderOption[], level: number): string {
1146
+ const selectedValue = this._activePath[level];
1147
+ const selectedPathValues = this._selectedPaths.flatMap((p) => p);
1148
+
1149
+ const optionsHtml = options
1150
+ .map((option) => {
1151
+ const isActive = option.value === selectedValue;
1152
+ const isSelected = this.multiple
1153
+ ? selectedPathValues.includes(option.value)
1154
+ : JSON.stringify(this._selectedPaths[0]) === JSON.stringify([...this._activePath.slice(0, level), option.value]);
1155
+ const isLoading = this._loadingKeys.has(option.value);
1156
+ const hasChildren = !this._isLeaf(option);
1157
+
1158
+ const classes = [
1159
+ 'cascader-option',
1160
+ isActive ? 'active' : '',
1161
+ isSelected ? 'selected' : '',
1162
+ option.disabled ? 'disabled' : '',
1163
+ ].filter(Boolean).join(' ');
1164
+
1165
+ return `
1166
+ <button
1167
+ type="button"
1168
+ class="${classes}"
1169
+ data-action="option"
1170
+ data-value="${this._escapeHtml(option.value)}"
1171
+ data-level="${level}"
1172
+ ${option.disabled ? 'disabled' : ''}
1173
+ >
1174
+ ${this.multiple ? `<span class="cascader-option-checkbox">${isSelected ? checkIcon : ''}</span>` : ''}
1175
+ <span class="cascader-option-label">${this._escapeHtml(option.label)}</span>
1176
+ ${isLoading ? `<span class="cascader-option-loading">${loadingIcon}</span>` : ''}
1177
+ ${hasChildren && !isLoading ? `<span class="cascader-option-arrow">${chevronRightIcon}</span>` : ''}
1178
+ </button>
1179
+ `;
1180
+ })
1181
+ .join('');
1182
+
1183
+ return `
1184
+ <div class="cascader-panel">
1185
+ <div class="cascader-panel-options">${optionsHtml}</div>
1186
+ </div>
1187
+ `;
1188
+ }
1189
+
1190
+ private _renderSearchResults(): string {
1191
+ const results = this._searchOptions();
1192
+
1193
+ if (results.length === 0) {
1194
+ return `<div class="cascader-empty">No results found</div>`;
1195
+ }
1196
+
1197
+ const selectedPathStrs = this._selectedPaths.map((p) => JSON.stringify(p));
1198
+
1199
+ const resultsHtml = results
1200
+ .map((result) => {
1201
+ const isSelected = selectedPathStrs.includes(JSON.stringify(result.pathValues));
1202
+ const classes = ['cascader-search-result', isSelected ? 'selected' : ''].filter(Boolean).join(' ');
1203
+
1204
+ const pathHtml = result.pathLabels
1205
+ .map((label, i) => {
1206
+ const separator = i < result.pathLabels.length - 1
1207
+ ? `<span class="cascader-search-result-separator">${this.separator}</span>`
1208
+ : '';
1209
+ return `<span>${this._escapeHtml(label)}</span>${separator}`;
1210
+ })
1211
+ .join('');
1212
+
1213
+ return `
1214
+ <button
1215
+ type="button"
1216
+ class="${classes}"
1217
+ data-action="search-result"
1218
+ data-path="${this._escapeHtml(JSON.stringify(result.pathValues))}"
1219
+ >
1220
+ ${this.multiple ? `<span class="cascader-option-checkbox">${isSelected ? checkIcon : ''}</span>` : ''}
1221
+ <span class="cascader-search-result-path">${pathHtml}</span>
1222
+ </button>
1223
+ `;
1224
+ })
1225
+ .join('');
1226
+
1227
+ return `<div class="cascader-search-results">${resultsHtml}</div>`;
1228
+ }
1229
+
1230
+ private _escapeHtml(str: string): string {
1231
+ const div = document.createElement('div');
1232
+ div.textContent = str;
1233
+ return div.innerHTML;
1234
+ }
1235
+
1236
+ protected update(): void {
1237
+ // Preserve search input focus state before DOM replacement
1238
+ const searchInput = this.shadowRoot?.querySelector('.cascader-search-input') as HTMLInputElement;
1239
+ const hadFocus = searchInput && this.shadowRoot?.activeElement === searchInput;
1240
+ const cursorPosition = hadFocus ? searchInput.selectionStart : null;
1241
+
1242
+ super.update();
1243
+ // Event delegation is set up once in connectedCallback
1244
+
1245
+ // If dropdown is open, re-show the popover after DOM update
1246
+ // (since update() replaces the DOM, the new dropdown element needs showPopover())
1247
+ if (this._isOpen) {
1248
+ const dropdown = this.shadowRoot?.querySelector('.cascader-dropdown') as HTMLElement;
1249
+ const trigger = this.shadowRoot?.querySelector('.cascader-trigger') as HTMLElement;
1250
+ if (dropdown && trigger) {
1251
+ try {
1252
+ dropdown.showPopover();
1253
+ this._positionDropdown(dropdown, trigger);
1254
+ } catch {
1255
+ // Ignore if already shown or other errors
1256
+ }
1257
+ }
1258
+
1259
+ // Restore search input focus if it had focus before update
1260
+ if (hadFocus) {
1261
+ const newSearchInput = this.shadowRoot?.querySelector('.cascader-search-input') as HTMLInputElement;
1262
+ if (newSearchInput) {
1263
+ newSearchInput.focus();
1264
+ // Restore cursor position
1265
+ if (cursorPosition !== null) {
1266
+ newSearchInput.setSelectionRange(cursorPosition, cursorPosition);
1267
+ }
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Set up event delegation once (called in connectedCallback)
1275
+ */
1276
+ private _setupEventDelegation(): void {
1277
+ // Use event delegation on shadow root for all interactive elements
1278
+ this.shadowRoot?.addEventListener('click', (e) => {
1279
+ const target = e.target as HTMLElement;
1280
+
1281
+ // Toggle trigger
1282
+ const trigger = target.closest('[data-action="toggle"]');
1283
+ if (trigger && !target.closest('[data-action="clear"]') && !target.closest('[data-action="remove-tag"]')) {
1284
+ this._toggle();
1285
+ return;
1286
+ }
1287
+
1288
+ // Clear button
1289
+ if (target.closest('[data-action="clear"]')) {
1290
+ this._handleClear(e);
1291
+ return;
1292
+ }
1293
+
1294
+ // Tag remove button
1295
+ const removeTag = target.closest('[data-action="remove-tag"]');
1296
+ if (removeTag) {
1297
+ e.stopPropagation();
1298
+ const index = parseInt(removeTag.getAttribute('data-index') || '0', 10);
1299
+ this._removeTag(index);
1300
+ return;
1301
+ }
1302
+
1303
+ // Panel option
1304
+ const option = target.closest('[data-action="option"]');
1305
+ if (option) {
1306
+ const value = option.getAttribute('data-value');
1307
+ const level = parseInt(option.getAttribute('data-level') || '0', 10);
1308
+ if (value) {
1309
+ this._handleOptionClick(value, level);
1310
+ }
1311
+ return;
1312
+ }
1313
+
1314
+ // Search result
1315
+ const searchResult = target.closest('[data-action="search-result"]');
1316
+ if (searchResult) {
1317
+ const pathStr = searchResult.getAttribute('data-path');
1318
+ if (pathStr) {
1319
+ try {
1320
+ const pathValues = JSON.parse(pathStr) as string[];
1321
+ const result: SearchResult = {
1322
+ pathValues,
1323
+ path: [],
1324
+ pathLabels: this._getPathLabels(pathValues),
1325
+ };
1326
+ this._selectSearchResult(result);
1327
+ } catch {
1328
+ // Invalid path
1329
+ }
1330
+ }
1331
+ return;
1332
+ }
1333
+ });
1334
+
1335
+ // Input event for search
1336
+ this.shadowRoot?.addEventListener('input', (e) => {
1337
+ const target = e.target as HTMLElement;
1338
+ if (target.matches('[data-action="search"]')) {
1339
+ this._handleSearch(e);
1340
+ }
1341
+ });
1342
+
1343
+ // Mouseenter for hover expand
1344
+ this.shadowRoot?.addEventListener('mouseenter', (e) => {
1345
+ if (this.expandTrigger !== 'hover') return;
1346
+ const target = e.target as HTMLElement;
1347
+ const option = target.closest('[data-action="option"]');
1348
+ if (option) {
1349
+ const value = option.getAttribute('data-value');
1350
+ const level = parseInt(option.getAttribute('data-level') || '0', 10);
1351
+ if (value) {
1352
+ this._handleOptionHover(value, level);
1353
+ }
1354
+ }
1355
+ }, true); // Use capture for mouseenter
1356
+ }
1357
+ }
1358
+
1359
+ /**
1360
+ * Register the custom element
1361
+ */
1362
+ export function register(): void {
1363
+ if (!customElements.get('el-dm-cascader')) {
1364
+ customElements.define('el-dm-cascader', ElDmCascader);
1365
+ }
1366
+ }