PageSourceSearch

https://www.eventeny.com/js/Layouts/StepperLayout.js?v=1790795785

js eventeny.com collected 2026-10-01 10:27:16 UTC 8,661 bytes, 219 lines download raw bytes

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.