@dile/ui 2.25.7 → 2.27.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.
@@ -1,4 +1,5 @@
1
1
  import { html, css, LitElement } from "lit";
2
+ import '../../spinner/spinner-icon.js';
2
3
 
3
4
  export class DileButton extends LitElement {
4
5
 
@@ -7,6 +8,7 @@ export class DileButton extends LitElement {
7
8
  static get properties() {
8
9
  return {
9
10
  disabled: { type: Boolean },
11
+ loading: { type: Boolean, reflect: true },
10
12
  name: { type: String },
11
13
  type: { type: String },
12
14
  };
@@ -15,6 +17,7 @@ export class DileButton extends LitElement {
15
17
  constructor() {
16
18
  super();
17
19
  this.disabled = false;
20
+ this.loading = false;
18
21
  this.type = "button";
19
22
  this._internals = this.attachInternals();
20
23
  }
@@ -78,6 +81,35 @@ export class DileButton extends LitElement {
78
81
  :host([disabled]) {
79
82
  pointer-events: none;
80
83
  }
84
+
85
+ :host([loading]) button {
86
+ cursor: auto;
87
+ background-color: var(--dile-button-disabled-background-color, var(--dile-button-background-color, #ccc));
88
+ color: var(--dile-button-disabled-text-color, var(--dile-button-text-color, #999));
89
+ border-color: var(--dile-button-disabled-border-color, var(--dile-button-border-color, #bbb));
90
+ }
91
+ :host([loading]) button:focus {
92
+ outline: none;
93
+ box-shadow: none;
94
+ }
95
+ :host([loading]) button:active {
96
+ outline: none;
97
+ border-color: #aaa;
98
+ box-shadow: none;
99
+ }
100
+ :host([loading]) {
101
+ pointer-events: none;
102
+ }
103
+ button {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ gap: var(--dile-button-spinner-gap, 0.5rem);
108
+ }
109
+ dile-spinner-icon {
110
+ --dile-icon-size: var(--dile-button-spinner-size, calc(var(--dile-button-font-size, 1rem) * 0.8));
111
+ --dile-icon-color: var(--dile-on-primary-color, #fff);
112
+ }
81
113
  `;
82
114
  }
83
115
 
@@ -85,14 +117,17 @@ export class DileButton extends LitElement {
85
117
  return html`
86
118
  <button
87
119
  @click=${this._onClick}
88
- ?disabled=${this.disabled}
120
+ ?disabled=${this.disabled || this.loading}
89
121
  type=${this.type || "button"}
90
- ><slot></slot></button>
122
+ >
123
+ ${this.loading ? html`<dile-spinner-icon active></dile-spinner-icon>` : ''}
124
+ <slot></slot>
125
+ </button>
91
126
  `;
92
127
  }
93
128
 
94
129
  _onClick(e) {
95
- if (this.disabled) {
130
+ if (this.disabled || this.loading) {
96
131
  e.preventDefault();
97
132
  e.stopPropagation();
98
133
  return;
@@ -0,0 +1 @@
1
+ export { DileOtpInput } from './src/DileOtpInput.js';
@@ -0,0 +1,3 @@
1
+ import { DileOtpInput } from './src/DileOtpInput.js';
2
+
3
+ window.customElements.define('dile-otp-input', DileOtpInput);
@@ -0,0 +1,326 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { DileEmmitChange } from '../../../mixins/form/index.js';
3
+ import { messageStyles } from '../../input/index.js';
4
+ import { sanitizeOtpValue } from '../../../lib/otp/sanitizeOtpValue.js';
5
+
6
+ export class DileOtpInput extends DileEmmitChange(LitElement) {
7
+
8
+ /**
9
+ * Fired when user press enter key.
10
+ *
11
+ * @event enter-pressed
12
+ */
13
+
14
+ /**
15
+ * Fired when value changes, alongside the generic `element-changed` event.
16
+ *
17
+ * @event dile-otp-input-changed
18
+ */
19
+
20
+ /**
21
+ * Fired once, when the last empty box has just been filled and the code is complete.
22
+ *
23
+ * @event dile-otp-input-completed
24
+ */
25
+
26
+ static get properties() {
27
+ return {
28
+ /** Label to the element */
29
+ label: { type: String },
30
+
31
+ /** Number of characters (boxes) of the code */
32
+ length: { type: Number },
33
+
34
+ /** Set initial value to the input. This property syncs to each box value */
35
+ value: { type: String },
36
+
37
+ /** Name for this input field */
38
+ name: { type: String },
39
+
40
+ /** Disable the input field */
41
+ disabled: { type: Boolean },
42
+
43
+ /** ReadOnly attribute */
44
+ readonly: { type: Boolean },
45
+
46
+ /** Has error on this input field */
47
+ errored: { type: Boolean },
48
+
49
+ /** Message Displayed */
50
+ message: { type: String },
51
+
52
+ /** Hide errors on input */
53
+ hideErrorOnInput: { type: Boolean },
54
+
55
+ /** Set the application focus to this the input component after the initialization */
56
+ focusOnStart: { type: Boolean },
57
+ };
58
+ }
59
+
60
+ static get formAssociated() {
61
+ return true;
62
+ }
63
+
64
+ constructor() {
65
+ super();
66
+ this.label = '';
67
+ this.value = '';
68
+ this.name = '';
69
+ this.length = 6;
70
+ this.disabled = false;
71
+ this.internals = this.attachInternals();
72
+ }
73
+
74
+ firstUpdated() {
75
+ if (this.focusOnStart) {
76
+ this.focus();
77
+ }
78
+ }
79
+
80
+ updated(changedProperties) {
81
+ if (changedProperties.has('value')) {
82
+ this.emmitChange();
83
+ this.internals.setFormValue(this.value);
84
+ this.dispatchEvent(new CustomEvent('dile-otp-input-changed', {
85
+ bubbles: true,
86
+ composed: true,
87
+ detail: {
88
+ name: this.name,
89
+ value: this.value
90
+ }
91
+ }));
92
+
93
+ const previousValue = changedProperties.get('value') || '';
94
+ if (this.value.length === this.length && previousValue.length !== this.length) {
95
+ this.dispatchEvent(new CustomEvent('dile-otp-input-completed', {
96
+ bubbles: true,
97
+ composed: true,
98
+ detail: {
99
+ name: this.name,
100
+ value: this.value
101
+ }
102
+ }));
103
+ }
104
+ }
105
+ }
106
+
107
+ static get styles() {
108
+ return [
109
+ messageStyles,
110
+ css`
111
+ * {
112
+ box-sizing: border-box;
113
+ }
114
+ :host {
115
+ display: block;
116
+ margin-bottom: 10px;
117
+ }
118
+ main {
119
+ width: var(--dile-otp-input-section-width, auto);
120
+ }
121
+ label {
122
+ display: block;
123
+ margin-bottom: var(--dile-otp-input-label-margin-bottom, 4px);
124
+ font-size: var(--dile-otp-input-label-font-size, 1em);
125
+ color: var(--dile-otp-input-label-color, #59e);
126
+ font-weight: var(--dile-otp-input-label-font-weight, normal);
127
+ }
128
+ section.for-input {
129
+ display: flex;
130
+ gap: var(--dile-otp-input-gap, 8px);
131
+ }
132
+ input {
133
+ box-sizing: border-box;
134
+ width: var(--dile-otp-input-box-size, 2.5em);
135
+ height: var(--dile-otp-input-box-size, 2.5em);
136
+ padding: 0;
137
+ text-align: center;
138
+ border-radius: var(--dile-otp-input-border-radius, 5px);
139
+ border: var(--dile-otp-input-border-width, 1px) solid var(--dile-otp-input-border-color, #888);
140
+ font-size: var(--dile-otp-input-font-size, 1.25em);
141
+ background-color: var(--dile-otp-input-background-color, #fff);
142
+ color: var(--dile-otp-input-color, #303030);
143
+ }
144
+ input:focus {
145
+ outline: none;
146
+ border-color: var(--dile-otp-input-focus-border-color, #6af);
147
+ }
148
+ input:disabled {
149
+ background-color: #f5f5f5;
150
+ border-color: var(--dile-otp-input-disabled-border-color, #eee);
151
+ }
152
+ .errored {
153
+ border-color: var(--dile-otp-input-error-border-color, #c00);
154
+ background-color: var(--dile-otp-input-error-background-color, var(--dile-otp-input-background-color, #fff));
155
+ }
156
+ `
157
+ ];
158
+ }
159
+
160
+ render() {
161
+ return html`
162
+ <main>
163
+ ${this.label
164
+ ? html`<label>${this.label}</label>`
165
+ : ''}
166
+ <section class="for-input">
167
+ ${this.boxesChars.map((char, index) => html`
168
+ <input
169
+ type="text"
170
+ inputmode="numeric"
171
+ pattern="[0-9]*"
172
+ maxlength="1"
173
+ autocomplete="${index === 0 ? 'one-time-code' : 'off'}"
174
+ ?disabled="${this.disabled}"
175
+ ?readonly="${this.readonly}"
176
+ .value="${char}"
177
+ class="${this.errored ? 'errored' : ''}"
178
+ @input="${(e) => this._onInput(e, index)}"
179
+ @keydown="${(e) => this._onKeydown(e, index)}"
180
+ @keypress="${this._lookForEnter}"
181
+ @paste="${(e) => this._onPaste(e)}"
182
+ @focus="${this._onFocus}"
183
+ />
184
+ `)}
185
+ </section>
186
+ ${this.messageTemplate}
187
+ </main>
188
+ `;
189
+ }
190
+
191
+ get messageTemplate() {
192
+ return html`
193
+ ${this.message
194
+ ? html`<div class="message ${this.errored ? 'errored-msg' : ''}"><span>${this.message}</span></div>`
195
+ : ''
196
+ }
197
+ `;
198
+ }
199
+
200
+ /**
201
+ * The current value split into one character per box, padded with empty strings.
202
+ */
203
+ get boxesChars() {
204
+ return Array.from({ length: this.length }, (_, index) => this.value.charAt(index));
205
+ }
206
+
207
+ /**
208
+ * The rendered box input elements, in order.
209
+ */
210
+ get boxes() {
211
+ return Array.from(this.shadowRoot.querySelectorAll('input'));
212
+ }
213
+
214
+ _setChar(index, char) {
215
+ const chars = this.boxesChars;
216
+ chars[index] = char;
217
+ this.value = chars.join('');
218
+ }
219
+
220
+ _onInput(e, index) {
221
+ const raw = e.target.value;
222
+
223
+ // Autofill (e.g. iOS/Android SMS autofill) can drop the whole code into a single box
224
+ if (raw.length > 1) {
225
+ const digits = sanitizeOtpValue(raw, this.length);
226
+ if (digits) {
227
+ if (this.hideErrorOnInput && this.errored) {
228
+ this.clearError();
229
+ }
230
+ this.value = digits;
231
+ this._focusBox(Math.min(digits.length, this.length - 1));
232
+ } else {
233
+ e.target.value = this.boxesChars[index];
234
+ }
235
+ return;
236
+ }
237
+
238
+ const digit = raw.replace(/\D/g, '');
239
+ if (!digit && raw !== '') {
240
+ e.target.value = this.boxesChars[index];
241
+ return;
242
+ }
243
+
244
+ if (this.hideErrorOnInput && this.errored) {
245
+ this.clearError();
246
+ }
247
+
248
+ this._setChar(index, digit);
249
+
250
+ if (digit && index < this.length - 1) {
251
+ this._focusBox(index + 1);
252
+ }
253
+ }
254
+
255
+ _onKeydown(e, index) {
256
+ if (e.key === 'Backspace' && !this.boxesChars[index] && index > 0) {
257
+ e.preventDefault();
258
+ this._focusBox(index - 1);
259
+ return;
260
+ }
261
+ if (e.key === 'ArrowLeft' && index > 0) {
262
+ e.preventDefault();
263
+ this._focusBox(index - 1);
264
+ return;
265
+ }
266
+ if (e.key === 'ArrowRight' && index < this.length - 1) {
267
+ e.preventDefault();
268
+ this._focusBox(index + 1);
269
+ return;
270
+ }
271
+ if (e.key.length === 1 && !/[0-9]/.test(e.key) && !e.ctrlKey && !e.metaKey) {
272
+ e.preventDefault();
273
+ }
274
+ }
275
+
276
+ _onPaste(e) {
277
+ e.preventDefault();
278
+ const pasted = (e.clipboardData || window.clipboardData).getData('text');
279
+ const digits = sanitizeOtpValue(pasted, this.length);
280
+ if (!digits) {
281
+ return;
282
+ }
283
+ if (this.hideErrorOnInput && this.errored) {
284
+ this.clearError();
285
+ }
286
+ this.value = digits;
287
+ this._focusBox(Math.min(digits.length, this.length - 1));
288
+ }
289
+
290
+ _onFocus(e) {
291
+ e.target.select();
292
+ }
293
+
294
+ _focusBox(index) {
295
+ const input = this.boxes[index];
296
+ if (input) {
297
+ input.focus();
298
+ input.select();
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Private method to dispatch events on enter key pressed
304
+ */
305
+ _lookForEnter(e) {
306
+ let keycode = (e.keyCode ? e.keyCode : e.which);
307
+ if (keycode == '13') {
308
+ this.dispatchEvent(new CustomEvent('enter-pressed'));
309
+ }
310
+ }
311
+
312
+ clearError() {
313
+ this.errored = false;
314
+ this.message = '';
315
+ }
316
+
317
+ focus() {
318
+ const emptyIndex = this.boxesChars.findIndex((char) => !char);
319
+ this._focusBox(emptyIndex === -1 ? this.length - 1 : emptyIndex);
320
+ }
321
+
322
+ clear() {
323
+ this.value = '';
324
+ this.focus();
325
+ }
326
+ }
@@ -0,0 +1,4 @@
1
+ export function sanitizeOtpValue(rawText, length) {
2
+ const digits = String(rawText ?? '').replace(/\D/g, '');
3
+ return length ? digits.slice(0, length) : digits;
4
+ }
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { sanitizeOtpValue } from './sanitizeOtpValue.js';
4
+
5
+ test('returns empty string for empty input', () => {
6
+ assert.equal(sanitizeOtpValue('', 6), '');
7
+ });
8
+
9
+ test('strips non-digit characters', () => {
10
+ assert.equal(sanitizeOtpValue('12 34-56', 6), '123456');
11
+ });
12
+
13
+ test('truncates to the given length', () => {
14
+ assert.equal(sanitizeOtpValue('1234567890', 6), '123456');
15
+ });
16
+
17
+ test('returns empty string for null or undefined', () => {
18
+ assert.equal(sanitizeOtpValue(null, 6), '');
19
+ assert.equal(sanitizeOtpValue(undefined, 6), '');
20
+ });
21
+
22
+ test('keeps digits shorter than length untouched', () => {
23
+ assert.equal(sanitizeOtpValue('12', 6), '12');
24
+ });
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@dile/ui",
3
- "version": "2.25.7",
3
+ "version": "2.27.0",
4
4
  "description": "UI Core components from dile-components.",
5
5
  "main": "index.js",
6
+ "type": "module",
6
7
  "scripts": {
7
8
  "test": "echo \"Error: no test specified\" && exit 1"
8
9
  },
@@ -26,5 +27,5 @@
26
27
  "publishConfig": {
27
28
  "access": "public"
28
29
  },
29
- "gitHead": "ecadaba1be6a48b96901bb20b3e5a2547bc56da7"
30
+ "gitHead": "5c04395f81c01fd289869b1b99d427d482c516a5"
30
31
  }