1/** 2 * @class StepperLayout 3 * @extends HTMLElement 4 * 5 * @classdesc A compact "stepper" control: two independently focusable icon buttons 6 * (start/end) with a value display between them, styled as a single pill-shaped 7 * control (mirrors button-element's `variant="secondary"` look). Built to replace 8 * button-element's `icon-click-only` attribute, which packed two click zones into a 9 * single native <button> and left keyboard users with no way to activate either zone 10 * (Enter/Space on that button did nothing). Here each zone is a real <button>, so 11 * Tab/Enter/Space work natively with no custom keyboard handling. 12 * 13 * Accessible names for the two zones are NOT generated by this component: the 14 * slotted icon element assigned to `icon-start` / `icon-end` must carry its own 15 * `aria-label` (e.g. `<i class="uil uil-minus" slot="icon-start" aria-label="Decrease quantity">`), 16 * which this component promotes onto the wrapping <button>. Value changes are mirrored 17 * into a visually-hidden `aria-live="polite"` region so they're announced without moving focus. 18 * 19 * @example 20 * <stepper-layout value="3"> 21 * <i class="uil uil-minus" slot="icon-start" aria-label="Decrease quantity"></i> 22 * <i class="uil uil-plus" slot="icon-end" aria-label="Increase quantity"></i> 23 * </stepper-layout> 24 * 25 * @attribute {string} variant - Optional. Visual style of the container. Default: "secondary". 26 * @attribute {string} size - Optional. small, medium (default), large. 27 * @attribute {boolean} disabled - Optional. Disables both inner buttons. 28 * @attribute {number} value - The current value shown in the center. 29 * @attribute {string} value-label - Optional. Name used in the aria-live mirror (e.g. "Quantity"). Defaults to "Value". 30 * 31 * @slot icon-start - Leading icon; clicking/activating it fires `click-left`. Must carry its own aria-label. 32 * @slot icon-end - Trailing icon; clicking/activating it fires `click-right`. Must carry its own aria-label. 33 * 34 * @event click-left - Fired when the start button is activated (click or keyboard). 35 * @event click-right - Fired when the end button is activated (click or keyboard). 36 */ 37class StepperLayout extends HTMLElement { 38 static get observedAttributes() { 39 return ['variant', 'size', 'disabled', 'value']; 40 } 41 42 constructor() { 43 super(); 44 this.attachShadow({ mode: 'open' }); 45 46 this.shadowRoot.innerHTML = ` 47 <style> 48 @import url('https://unicons.iconscout.com/release/v4.0.8/css/line.css'); 49 50 :host { 51 display: inline-block; 52 vertical-align: middle; 53 } 54 55 .container { 56 box-sizing: border-box; 57 display: inline-flex; 58 align-items: center; 59 height: var(--control-height, 40px); 60 padding: 4px; 61 border-radius: 4px; 62 background-color: var(--bg-default); 63 border: 1px solid var(--brand-primary); 64 overflow: hidden; 65 } 66 67 :host([disabled]) .container { 68 background-color: transparent; 69 border-color: var(--border-disabled); 70 pointer-events: none; 71 } 72 73 button { 74 all: unset; 75 box-sizing: border-box; 76 display: inline-flex; 77 justify-content: center; 78 align-items: center; 79 height: 100%; 80 padding: 0 12px; 81 border-radius: 2px; 82 color: var(--brand-primary); 83 cursor: pointer; 84 } 85 86 button:hover:not(:disabled) { 87 background-color: var(--bg-brand-primary); 88 } 89 90 button:focus-visible { 91 outline: 2px solid var(--border-focused); 92 outline-offset: -2px; 93 } 94 95 button:disabled { 96 color: var(--text-disabled);
97 cursor: not-allowed; 98 } 99 100 /* Same color/weight a variant="secondary" button's own label would have. */ 101 .value-label { 102 box-sizing: border-box; 103 font-family: 'Plus Jakarta Sans', 'Roboto', sans-serif; 104 font-size: 16px; 105 color: var(--brand-primary); 106 text-align: center; 107 min-width: 24px; 108 padding: 0 4px; 109 } 110 111 :host([size="small"]) .container { 112 height: 28px; 113 } 114 115 :host([size="small"]) button { 116 padding: 0 8px; 117 font-size: 18px; 118 } 119 120 :host([size="small"]) .value-label { 121 font-size: 18px; 122 } 123 124 :host([size="large"]) .container { 125 height: 48px; 126 } 127 128 .sr-only { 129 position: absolute; 130 width: 1px; 131 height: 1px; 132 padding: 0; 133 margin: -1px; 134 overflow: hidden; 135 clip: rect(0, 0, 0, 0); 136 white-space: nowrap; 137 border: 0; 138 } 139 </style> 140 141 <div class="container" part="container"> 142 <button type="button" part="btn-start"> 143 <slot name="icon-start"></slot> 144 </button> 145 <span class="value-label" part="value-label"></span> 146 <button type="button" part="btn-end"> 147 <slot name="icon-end"></slot> 148 </button> 149 </div> 150 <span class="sr-only" aria-live="polite"></span> 151 `; 152 153 this._btnStart = this.shadowRoot.querySelector('button[part="btn-start"]'); 154 this._btnEnd = this.shadowRoot.querySelector('button[part="btn-end"]'); 155 this._valueLabel = this.shadowRoot.querySelector('.value-label'); 156 this._liveRegion = this.shadowRoot.querySelector('.sr-only'); 157 this._iconStartSlot = this.shadowRoot.querySelector('slot[name="icon-start"]'); 158 this._iconEndSlot = this.shadowRoot.querySelector('slot[name="icon-end"]'); 159 160 this._btnStart.addEventListener('click', () => { 161 this.dispatchEvent(new CustomEvent('click-left', { bubbles: true, composed: true })); 162 }); 163 this._btnEnd.addEventListener('click', () => { 164 this.dispatchEvent(new CustomEvent('click-right', { bubbles: true, composed: true })); 165 }); 166 167 this._iconStartSlot.addEventListener('slotchange', () => this._syncButtonLabel(this._iconStartSlot, this._btnStart)); 168 this._iconEndSlot.addEventListener('slotchange', () => this._syncButtonLabel(this._iconEndSlot, this._btnEnd)); 169 } 170 171 connectedCallback() { 172 this._updateDisabled(); 173 this._updateValue(); 174 this._syncButtonLabel(this._iconStartSlot, this._btnStart); 175 this._syncButtonLabel(this._iconEndSlot, this._btnEnd); 176 } 177 178 attributeChangedCallback(name) { 179 switch (name) { 180 case 'disabled': 181 this._updateDisabled(); 182 break; 183 case 'value': 184 this._updateValue(); 185 break; 186 } 187 } 188 189 // Promotes the slotted icon's own aria-label onto the wrapping <button>, since the 190 // icon itself (e.g. aria-hidden or decorative) is no longer the accessible name source 191 // once it lives inside a real interactive button. 192 _syncButtonLabel(slot, button) { 193 const icon = slot.assignedElements({ flatten: true })[0]; 194 const label = icon?.getAttribute('aria-label'); 195 if (label) { 196 button.setAttribute('aria-label', label); 197 } else { 198 button.removeAttribute('aria-label'); 199 } 200 } 201 202 _updateDisabled() { 203 const disabled = this.hasAttribute('disabled'); 204 this._btnStart.disabled = disabled; 205 this._btnEnd.disabled = disabled; 206 } 207
208 _updateValue() { 209 const value = this.getAttribute('value') || '0'; 210 this._valueLabel.textContent = value; 211 212 const label = this.getAttribute('value-label') || 'Value'; 213 this._liveRegion.textContent = `${label}: ${value}`; 214 } 215} 216 217if (!customElements.get('stepper-layout')) { 218 customElements.define('stepper-layout', StepperLayout); 219}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.