-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdemo-issues.html
More file actions
1270 lines (1105 loc) · 48.9 KB
/
demo-issues.html
File metadata and controls
1270 lines (1105 loc) · 48.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Popover Composition Issue Demo</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
line-height: 1.6;
}
.demo-section {
margin: 40px 0;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
h2 {
margin-top: 0;
color: #333;
}
/* Custom button styling */
custom-button {
display: inline-block;
margin: 10px;
}
/* Custom input styling */
my-input {
display: inline-block;
margin: 10px;
}
/* Label custom button styling */
label-custom-button {
display: inline-block;
margin: 10px;
}
/* Aria button styling */
aria-button {
display: inline-block;
margin: 10px;
}
/* Popover styling */
.my-popover {
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
background: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 350px;
}
.my-popover::backdrop {
background: rgba(0, 0, 0, 0.3);
}
/* Standard button for comparison */
.standard-btn {
padding: 10px 16px;
background: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.standard-btn:hover {
background: #005a9e;
}
/* Code styling for better readability */
code {
background-color: #2d3748;
border: 1px solid #4a5568;
border-radius: 4px;
padding: 3px 6px;
font-family: 'Fira Code', 'Cascadia Code', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 1.1em;
color: #e2e8f0;
font-weight: 500;
letter-spacing: 0.025em;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
pre code {
background-color: #f8f9fa;
border: none;
padding: 0;
color: #212529;
display: block;
white-space: pre;
overflow-x: auto;
}
pre {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
padding: 15px;
margin: 15px 0;
overflow-x: auto;
font-size: 0.9em;
line-height: 1.4;
}
</style>
</head>
<body>
<h1>Popover API x custom elements</h1>
<h2>Ana Sollano Kim</h2>
<h3>November 2025</h3>
<div class="demo-section">
<h2>1. Native button with popover</h2>
<p>A native button can use <code>popovertarget</code>:</p>
<p><strong>✨ Purely Declarative</strong></p>
<pre><code><button popovertarget="simple-popover">Open Popover</button>
<div id="simple-popover" popover aria-labelledby="simple-title">
<h3 id="simple-title">Simple Popover</h3>
<p>Content here...</p>
<button popovertarget="simple-popover" popovertargetaction="hide">Close</button>
</div></code></pre>
<button class="standard-btn" popovertarget="simple-popover">Open Popover</button>
<div id="simple-popover" popover class="my-popover" aria-labelledby="simple-title">
<h3 id="simple-title">Simple Popover</h3>
<p>This popover is triggered by a native button using the <code>popovertarget</code> attribute.</p>
<button popovertarget="simple-popover" popovertargetaction="hide">Close</button>
</div>
</div>
<div class="demo-section">
<h2>2. Custom element with button in shadow DOM</h2>
<p>This custom element tries to leverage a shadowed button by reflecting the <code>popovertarget</code> attribute:</p>
<p><strong>⚙️ Requires JavaScript</strong> - Even with attribute reflection, <code>popovertarget</code> doesn't work across shadow DOM boundaries.</p>
<ul>
<li><code>popovertarget</code> requires both trigger and target in the same document tree.</li>
<li>The popover target is in light DOM, but the button is in shadow DOM.</li>
</ul>
<pre><code><custom-button popovertarget="custom-popover">Open Popover</custom-button>
<div id="custom-popover" popover aria-labelledby="custom-title">
<h3 id="custom-title">Custom Element Popover</h3>
<p>Content here...</p>
</div>
<script>
class CustomButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.innerHTML = '<slot></slot>';
this.shadowRoot.appendChild(button);
// Store reference for attribute reflection
this.button = button;
}
connectedCallback() {
// Try to reflect popovertarget to the internal button
this.reflectPopovertarget();
}
reflectPopovertarget() {
const target = this.getAttribute('popovertarget');
if (target) {
// Attempt to make the shadow button work with popovertarget
this.button.setAttribute('popovertarget', target);
}
}
}
</script></code></pre>
<custom-button popovertarget="custom-popover">Open Popover</custom-button>
<div id="custom-popover" popover class="my-popover" aria-labelledby="custom-title"></div>
</div>
<div class="demo-section">
<h2>3. Custom element that reimplements popovertarget</h2>
<p>A custom element that acts as the invoker itself and reimplements <code>popovertarget</code> functionality:</p>
<p><strong>⚙️ Requires JavaScript</strong> - Must implement button semantics, event handling, and popover logic.</p>
<pre><code><popover-invoker popovertarget="reimplemented-popover">Open Popover</popover-invoker>
<div id="reimplemented-popover" popover aria-labelledby="reimplemented-title">
<h3 id="reimplemented-title">Reimplemented Popover</h3>
<p>Content here...</p>
</div>
<script>
class PopoverInvoker extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
this.internals.role = 'button';
this.internals.ariaExpanded = 'false';
this.setAttribute('tabindex', '0');
this.addEventListener('click', this.togglePopover.bind(this));
this.addEventListener('keydown', this.handleKeydown.bind(this));
}
handleKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.togglePopover();
}
}
togglePopover() {
const target = this.getAttribute('popovertarget');
if (target) {
const popover = document.getElementById(target);
if (popover) {
if (popover.matches(':popover-open')) {
popover.hidePopover();
this.internals.ariaExpanded = 'false';
} else {
popover.showPopover();
this.internals.ariaExpanded = 'true';
}
}
}
}
}
</script></code></pre>
<popover-invoker popovertarget="reimplemented-popover">
Open Popover
</popover-invoker>
<div id="reimplemented-popover" popover class="my-popover" aria-labelledby="reimplemented-title">
<h3 id="reimplemented-title">Reimplemented Popover</h3>
<p>This popover is triggered by a custom element that reimplements <code>popovertarget</code> functionality directly.</p>
<p>This approach:</p>
<ul>
<li>Uses <code>ElementInternals</code> for ARIA attributes and adds <code>tabindex</code> for keyboard handling.</li>
<li>Programmatically calls <code>showPopover()</code>/<code>hidePopover()</code>.</li>
</ul>
<button onclick="document.getElementById('reimplemented-popover').hidePopover()">Close</button>
</div>
</div>
<div class="demo-section">
<h2>4. Shadowing both button and popover</h2>
<p>Puts both the button and popover in the shadow DOM. Works with native <code>popovertarget</code> since both elements are in the same shadow tree.</p>
<p><strong>⚙️ Requires JavaScript</strong> - Imperative Custom Element definition.</p>
<ul>
<li>Can't mix and match different popover content with different buttons.</li>
<li>Can't reuse button with different popovers.</li>
<li>Popover content is hardcoded in the component template.</li>
</ul>
<pre><code><fully-encapsulated-button>Open Popover</fully-encapsulated-button>
<script>
class FullyEncapsulatedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Both button and popover in shadow DOM
this.shadowRoot.innerHTML = `
<button id="btn" popovertarget="internal-popover"><slot></slot></button>
<div id="internal-popover" popover>
<h3>Internal Popover</h3>
<p>Content here...</p>
</div>
`;
}
}
</script></code></pre>
<fully-encapsulated-button>Open Popover</fully-encapsulated-button>
</div>
<div class="demo-section">
<h2>If custom elements supported the Popover API</h2>
<p><strong>⚙️ Requires JavaScript</strong> - Imperative custom element definition.</p>
<pre><code><custom-button popovertarget="popover">
<slot>Open Popover</slot>
</custom-button>
<div id="popover" popover>
<!-- Popover content -->
</div></code></pre>
<p>We have to make the custom button a "good" invoker.</p>
<ul>
<li>Add role button.</li>
<li>Enter and Space keys activate button.</li>
<li>Focusability.</li>
</ul>
<pre><code><script>
class CustomButton extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
this.internals.role = 'button';
this.setAttribute('tabindex', '0');
this.addEventListener('click', () => {
// Focus restoration has to be done when programmatically calling showPopover().
this.lastFocusedElement = document.activeElement;
// The browser should at least handle the click activation behavior.
});
// Restore focus on close.
document.addEventListener('beforetoggle', (event) => {
if (event.target.id === this.getAttribute('popovertarget')) {
if (event.newState === 'closed' && this.lastFocusedElement === this) {
this.focus();
}
}
});
// Should the browser handle the keyboard activation behavior?
this.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleActivation();
}
});
}
connectedCallback() {
const target = this.getAttribute('popovertarget');
if (target) {
// Set up popover association.
this.internals.ariaExpanded = 'false';
const popoverElement = document.getElementById(target);
if (popoverElement) {
// Set aria-controls to establish semantic relationship with popover.
this.internals.ariaControlsElements = [popoverElement];
}
}
}
handleActivation() {
const target = this.getAttribute('popovertarget');
if (target) {
const popover = document.getElementById(target);
if (popover) {
if (popover.matches(':popover-open')) {
popover.hidePopover();
this.internals.ariaExpanded = 'false';
} else {
popover.showPopover();
this.internals.ariaExpanded = 'true';
}
}
}
}
}
customElements.define('custom-button', CustomButton);
</script></code></pre>
<p>If browsers could bundle the button behavior, it would save the custom element author a lot of boilerplate code.</p>
<pre><code><custom-button popovertarget="popover">
<slot>Open Popover</slot>
</custom-button>
<div id="popover" popover>
<!-- Popover content -->
</div>
<script>
class CustomButton extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
this.internals.popoverTarget = this.getAttribute('popovertarget');
}
}
customElements.define('custom-button', CustomButton);
</script></code></pre>
<p>Browser would manage:</p>
<ul>
<li>Button role assignment.</li>
<li>Event listeners (click, keydown for Enter/Space).</li>
<li>ARIA attributes (aria-expanded, aria-controls).</li>
<li>Focus management and restoration.</li>
</ul>
<p>However, this approach would require the browser to automatically add button semantics to all custom elements that use the Popover API, which might cause:</p>
<ul>
<li>Unexpected behavior if the custom element is not intended to be a button.</li>
<li>Compatibility issues with custom elements that already use the <code>popovertarget</code> and <code>popover</code> attributes.</li>
<li>Confusion as to what default behaviors are included by just using the Popover API.</li>
</ul>
</div>
<div class="demo-section">
<h2>Unbundling into atomic/granular functionality</h2>
<p>Declare the features in <code>static elementFeatures</code> similar to <code>static formAssociated = true</code>:</p>
<pre><code><custom-button popovertarget="customs-popover">Open Popover</custom-button>
<div id="custom-popover" popover>
<!-- Popover content -->
</div>
<script>
class FeatureButton extends HTMLElement {
static elementFeatures = {
activation: true,
focusMode: 'control',
keyboardActivation: ['Enter', 'Space'],
popovertarget: true,
role: 'button',
};
constructor() {
super();
this.internals = this.attachInternals();
// Set up activation callback (only works if activation: true)
this.internals.onActivate = (event) => {
// Called by browser when user clicks or presses specified keys
console.log('Button activated!', event.type);
};
}
}
customElements.define('feature-button', FeatureButton);
</script></code></pre>
<p>Browser automatically configures functionality based on elementFeatures:</p>
<ul>
<li>Sets <code>this.internals.role = 'button'</code></li>
<li>Manages focusability</li>
<li>Listens for keyboard down events specified in <code>keyboardActivation</code></li>
<li>Handles popover behavior</li>
</ul>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<thead>
<tr style="background: #f5f5f5;">
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Feature</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Type</th>
<th style="border: 1px solid #ddd; padding: 8px; text-align: left;">Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><code>activation</code></td>
<td style="border: 1px solid #ddd; padding: 8px;">boolean</td>
<td style="border: 1px solid #ddd; padding: 8px;">Enables <code>this.internals.onActivate</code> method (like <code>formAssociated</code>
enables <code>setValidity</code>)</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><code>focusMode</code></td>
<td style="border: 1px solid #ddd; padding: 8px;">string</td>
<td style="border: 1px solid #ddd; padding: 8px;">Sets <code>this.internals.focusMode</code>.
Three possible values: 'none' (not focusable), 'text-input' (focusable, edits text), 'control' (focusable, does not edit text)</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><code>keyboardActivation</code></td>
<td style="border: 1px solid #ddd; padding: 8px;">string[]</td>
<td style="border: 1px solid #ddd; padding: 8px;">Browser listens for specified keys (requires <code>activation: true</code>)</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><code>popovertarget</code></td>
<td style="border: 1px solid #ddd; padding: 8px;">boolean</td>
<td style="border: 1px solid #ddd; padding: 8px;">Enables popover invoker behavior, manages ARIA attributes</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;"><code>role</code>(already available)</td>
<td style="border: 1px solid #ddd; padding: 8px;">string</td>
<td style="border: 1px solid #ddd; padding: 8px;">Sets <code>this.internals.role</code></td>
</tr>
</tbody>
</table>
<p>This approach:</p>
<ul>
<li>Enables the custom element author to mix and match features as needed.</li>
<li>Uses the same pattern as existing <code>static formAssociated</code>.</li>
<li>Makes behavior predictable since the custom element author is explicitly opting into selected behaviors.</li>
<li>Makes each feature handle its own accessibility semantics.</li>
</ul>
<p>Instead of static declarations, features could be configured directly in <code>attachInternals()</code>:</p>
<pre><code><custom-button popovertarget="popover">Open Popover</custom-button>
<div id="popover" popover>
<!-- Popover content -->
</div>
<script>
class CustomButton extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals({
features: {
focusMode: 'control',
keyboardActivation: ['Enter', 'Space'],
popovertarget: true,
role: 'button',
activation: true
}
});
this.internals.onActivate = (event) => {
console.log('Button activated!', event.type);
};
}
}
customElements.define('custom-button', CustomButton);
</script></code></pre>
<p>We could also allow custom elements to use <code>ElementInternals</code> to reflect <code>popovertarget</code> across shadow DOM boundaries.
Custom element reflects <code>popovertarget</code> to <code>this.internals.popoverTargetElement</code>, similar to <code>this.internals.ariaControlsElements</code>.</p>
<p><strong>⚙️ Requires JavaScript</strong> - Uses attribute reflection.</p>
<ul>
<li><code>this.internals.popoverTargetElement = element</code> takes an element reference. Based on how some accessibility properties in
<code>ElementInternals</code> work and are able to cross shadow DOM boundaries.</li>
<li><strong>What would the browser handle?</strong> Click events, keyboard activation, popover show/hide.</li>
<li>Button and popover remain separate, reusable components.</li>
</ul>
<pre><code><reference-button popovertarget="reference-popover">Open Popover</reference-button>
<div id="reference-popover" popover aria-labelledby="reference-title">
<h3 id="reference-title">Reference Target Popover</h3>
<p>Content here...</p>
</div>
<script>
class ReferenceButton extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
}
connectedCallback() {
const targetId = this.getAttribute('popovertarget');
if (targetId) {
const targetElement = document.getElementById(targetId);
if (targetElement) {
this.internals.popoverTargetElement = targetElement;
}
} else {
this.internals.popoverTargetElement = null;
}
}
}
</script></code></pre>
<p>While the granular feature approach provides flexibility, it also introduces potential for misuse:</p>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 15px; margin: 15px 0;">
<h4 style="margin-top: 0; color: #856404;">Example: focusable but not activatable</h4>
<pre style="margin: 10px 0;"><code>static elementFeatures = {
focusMode: 'control',
role: 'button',
activation: false
};</code></pre>
<p style="margin-bottom: 0;"><strong>Problem:</strong> Creates a "button" that screen readers can focus and announce as a button, but Space/Enter keys don't work.
This violates user expectations and accessibility guidelines.</p>
</div>
<ul>
<li><code>keyboardActivation: ['Enter']</code> without <code>role: 'button'</code> - users get keyboard behavior but no semantics.</li>
<li><code>popovertarget: true</code> without <code>this.internals.focusMode = 'control';</code> - creates inaccessible popover triggers.</li>
<li>Popover invoker that isn't a button or isn't focusable or can't be activated.</li>
</ul>
<h3>🛡️ Is this acceptable?</h3>
<div style="background: #d1ecf1; border: 1px solid #bee5eb; border-radius: 5px; padding: 15px; margin: 15px 0;">
<h4 style="margin-top: 0; color: #0c5460;">Should the platform provide guardrails?</h4>
<ul style="margin-bottom: 0;">
<li>Could "bad" combinations have legitimate use cases in advanced scenarios?</li>
<li>Does the browser need to provide safe presets?</li>
<li>As custom elements are inaccessible by default, authors are already expected to fill accessibility gaps themselves.</li>
</ul>
</div>
<h3>What about the submit behavior?</h3>
<p>Maybe we could have <code>this.#internals.submitButton = true;</code>?</p>
<div style="background: #e8f5e8; border: 1px solid #c3e6c3; border-radius: 5px; padding: 15px;">
<ul>
<li>Must be <code>formAssociated = true</code>, have button role, be focusable, and have keyboard activation.</li>
<li>Should trigger form submission on activation, submit event dispatching, form data collection.</li>
</ul>
</div>
<pre><code><form id="demo-form">
<label>
Name: <input type="text" name="userName" required>
</label>
<label>
Email: <input type="email" name="userEmail" required>
</label>
<!-- Custom submit button -->
<submit-button form="demo-form">Submit Form</submit-button>
</form>
<script>
class SubmitButton extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals({
features: {
activation: true,
focusMode: 'control',
keyboardActivation: ['Enter', 'Space'],
role: 'button',
submit: true,
formAssociated: true,
}
});
// Set up activation callback
this.internals.onActivate = (event) => {
console.log('Form submission initiated by custom button');
};
}
}
customElements.define('submit-button', SubmitButton);
</script></code></pre>
<p>With the <code>submit: true</code> feature, the browser would automatically have activation behavior that matches other submit buttons.</p>
<p>However, this proposal to add behavior via the <code>ElementInternals</code> API isn't too well received by some community members
who would instead like to have mixins.</p>
</div>
<h1>Label x custom elements</h1>
<label for="normal-input">Username:</label>
<input id="normal-input" name="username"></input>
<pre><code><label for="normal-input">Username:</label>
<input id="normal-input" name="username"></input></code></pre>
<div class="demo-section">
<h2>Form-Associated Custom Elements (FACEs) are labelable</h2>
<p>Form-associated custom elements can be properly labeled because they participate in the form labeling system:</p>
<pre><code><label for="my-input">Username:</label>
<my-input id="my-input" name="username"></my-input>
<script>
class MyInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
}
}
customElements.define('my-input', MyInput);
</script></code></pre>
<label for="my-input">Username:</label>
<my-input id="my-input" name="username"></my-input>
</div>
<div class="demo-section">
<h2>1. Custom element with shadow button cannot be labeled</h2>
<p>Custom element that contains a native button in its Shadow DOM, associated with a label that is outside in the light DOM.</p>
<p>❌ The <code>for</code> attribute cannot cross shadow DOM boundaries.</p>
<pre><code><label for="customButton">Click me:</label>
<label-custom-button id="customButton">Button</label-custom-button>
<script>
class LabelCustomButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<button><slot></slot></button>
`;
}
}
customElements.define('label-custom-button', LabelCustomButton);
</script></code></pre>
<label for="customButton">Click me:</label>
<label-custom-button id="customButton">Button</label-custom-button>
</div>
<div class="demo-section">
<h2>2. Custom label with shadow label cannot reference light DOM button</h2>
<p>Custom label that contains a native label in its Shadow DOM, referencing a button in the light DOM.</p>
<p>❌ The shadow label's <code>for</code> attribute cannot reference elements outside its shadow tree.</p>
<pre><code><custom-label for="button">Click me:</custom-label>
<button id="button">Submit</button>
<script>
class CustomLabel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<label><slot></slot></label>
`;
}
connectedCallback() {
const forValue = this.getAttribute('for');
if (forValue) {
// Try to set the for attribute on the internal label
const shadowLabel = this.shadowRoot.querySelector('label');
shadowLabel.setAttribute('for', forValue);
// This won't work because the target is outside the shadow DOM
}
}
}
customElements.define('custom-label', CustomLabel);
</script></code></pre>
<custom-label for="button">Click me:</custom-label>
<button id="button">Submit</button>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 15px;">
<ul>
<li>Clicking the label text doesn't focus the target button</li>
<li>Accessibility relationship is not established</li>
</ul>
</div>
</div>
<div class="demo-section">
<h2>Current workarounds</h2>
<h3>1. Element Reflection: <code>ariaDescribedByElements</code></h3>
<pre><code><label id="aria-label">Accessible label:</label>
<aria-button id="aria-button">Click Here</aria-button>
<script>
class AriaButton extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<button><slot></slot></button>
`;
}
connectedCallback() {
const labelElement = document.getElementById('aria-label');
this.internals.ariaLabelledByElements = [labelElement];
}
}
customElements.define('aria-button', AriaButton);
</script></code></pre>
<label id="aria-label">Accessible label:</label>
<aria-button id="aria-button">Click Here</aria-button>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 15px; margin: 15px 0;">
<ul>
<li>Screen readers will announce the label text.</li>
<li>Clicking the label text doesn't activate the button.</li>
</ul>
</div>
<h3>2. Using reference target for cross-boundary references</h3>
<p>The <code>referenceTarget</code> attribute on <code>ShadowRoot</code> allows specifying an element inside the shadow DOM that can be referenced from outside:</p>
<pre><code><reference-custom-label id="refCustomLabel">Custom Label</reference-custom-label>
<button id="refButton" aria-labelledby="refCustomLabel">Button</button>
<script>
class ReferenceCustomLabel extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<div>Decorative prefix</div>
<label id="actualLabel"><slot></slot></label>
<div>Decorative suffix</div>
`;
// Set reference target to the actual label element inside shadow DOM
const actualLabel = shadowRoot.getElementById('actualLabel');
shadowRoot.referenceTarget = actualLabel;
}
}
customElements.define('reference-custom-label', ReferenceCustomLabel);
</script></code></pre>
<reference-custom-label id="refCustomLabel">Custom Label</reference-custom-label>
<button id="refButton" aria-labelledby="refCustomLabel">Button</button>
<div style="background: #d1ecf1; border: 1px solid #bee5eb; border-radius: 5px; padding: 15px; margin: 15px 0;">
<ul>
<li><code>shadowRoot.referenceTarget</code> specifies which element inside the shadow DOM should be the target of external references.</li>
<li>When <code>aria-labelledby="refCustomLabel"</code> is used on the button, it resolves to the <code>actualLabel</code> element inside the shadow DOM.</li>
<li>This allows the custom label to have additional decorative elements while exposing the actual label for accessibility.</li>
<li>Screen readers properly announce the label text from inside the shadow DOM.</li>
</ul>
</div>
<div style="background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 15px; margin: 15px 0;">
<ul style="margin-bottom: 0;">
<li>Accessibility relationships work correctly.</li>
<li>Doesn't work with <code>for</code> attribute on <code><label></code> elements.</li>
<li>Only works with ARIA attributes (<code>aria-labelledby</code>, <code>aria-describedby</code>, etc.).</li>
<li><code>referenceTarget</code> solves accessibility references but doesn't provide clickable label behavior.</li>
</ul>
</div>
</div>
<script>
class CustomButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Create button in shadow DOM
const button = document.createElement('button');
button.innerHTML = `
<style>
button {
padding: 10px 16px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #218838;
}
button:focus {
outline: 2px solid #80bdff;
outline-offset: 2px;
}
</style>
<slot></slot>
`;
this.shadowRoot.appendChild(button);
// Store reference for attribute reflection
this.button = button;
}
connectedCallback() {
// Try to reflect popovertarget to the internal button
this.reflectPopovertarget();
}
static get observedAttributes() {
return ['popovertarget'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'popovertarget') {
this.reflectPopovertarget();
}
}
reflectPopovertarget() {
const target = this.getAttribute('popovertarget');
if (target && this.button) {
// Attempt to make the shadow button work with popovertarget
// This won't work because popover targets need to be in the same document tree
this.button.setAttribute('popovertarget', target);
// Since the above doesn't work, we still need manual implementation
this.button.addEventListener('click', () => {
const popover = document.getElementById(target);
if (popover) {
if (popover.matches(':popover-open')) {
popover.hidePopover();
} else {
popover.showPopover();
}
}
});
}
}
}
customElements.define('custom-button', CustomButton);
// Button and popover in shadow DOM
class FullyEncapsulatedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 16px;
background: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #c82333;
}
button:focus {
outline: 2px solid #80bdff;
outline-offset: 2px;
}
#internal-popover {
border: 1px solid #333;
border-radius: 8px;
padding: 20px;
background: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 300px;
}
</style>
<button id="internal-btn" popovertarget="internal-popover">
<slot></slot>
</button>
<div id="internal-popover" popover>
<h3>Internal Popover</h3>
<p>This popover is in the shadow DOM and uses native popover API.</p>
<ul>
<li>Uses native <code>popovertarget</code> and <code>popover</code> attributes</li>
<li>Can't be styled from outside or composed</li>
</ul>
<button popovertarget="internal-popover" popovertargetaction="hide">Close</button>
</div>
`;
// No custom event handling needed - native popover API handles everything!
}
}
customElements.define('fully-encapsulated-button', FullyEncapsulatedButton);
// Custom element that reimplements popovertarget functionality
class PopoverInvoker extends HTMLElement {
constructor() {
super();
this.internals = this.attachInternals();
this.internals.role = 'button';
this.internals.ariaExpanded = 'false';
this.setAttribute('tabindex', '0');
this.style.display = 'inline-block';
this.style.padding = '10px 16px';
this.style.background = '#6f42c1';
this.style.color = 'white';
this.style.border = 'none';
this.style.borderRadius = '4px';
this.style.cursor = 'pointer';
this.style.fontSize = '14px';
this.style.userSelect = 'none';
this.addEventListener('click', this.togglePopover.bind(this));
this.addEventListener('keydown', this.handleKeydown.bind(this));
this.addEventListener('mouseenter', () => {
this.style.background = '#5a2d91';
});
this.addEventListener('mouseleave', () => {
this.style.background = '#6f42c1';
});
}
handleKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.togglePopover();
}
}