@dile/ui 2.26.0 → 2.27.1

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,48 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import './button.js';
3
+
4
+ describe('dile-button', () => {
5
+ afterEach(() => {
6
+ document.body.innerHTML = '';
7
+ });
8
+
9
+ async function renderButton(html) {
10
+ document.body.innerHTML = html;
11
+ const el = document.body.querySelector('dile-button');
12
+ await el.updateComplete;
13
+ return el;
14
+ }
15
+
16
+ it('renders the slotted content inside a native button', async () => {
17
+ const el = await renderButton('<dile-button>Click me</dile-button>');
18
+
19
+ expect(el.shadowRoot.querySelector('button')).toBeTruthy();
20
+ expect(el.textContent.trim()).toBe('Click me');
21
+ });
22
+
23
+ it('reflects the disabled property onto the native button', async () => {
24
+ const el = await renderButton('<dile-button disabled>Click me</dile-button>');
25
+
26
+ expect(el.shadowRoot.querySelector('button').disabled).toBe(true);
27
+ });
28
+
29
+ it('dispatches a click event when enabled', async () => {
30
+ const el = await renderButton('<dile-button>Click me</dile-button>');
31
+ let clicked = false;
32
+ el.addEventListener('click', () => { clicked = true; });
33
+
34
+ el.shadowRoot.querySelector('button').click();
35
+
36
+ expect(clicked).toBe(true);
37
+ });
38
+
39
+ it('shows a spinner icon while loading', async () => {
40
+ const el = await renderButton('<dile-button>Save</dile-button>');
41
+ expect(el.shadowRoot.querySelector('dile-spinner-icon')).toBeNull();
42
+
43
+ el.loading = true;
44
+ await el.updateComplete;
45
+
46
+ expect(el.shadowRoot.querySelector('dile-spinner-icon')).toBeTruthy();
47
+ });
48
+ });
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { LitElement, html, css } from "lit";
2
+ import { repeat } from "lit/directives/repeat.js";
2
3
 
3
4
  export class DileToast extends LitElement {
4
5
  static get properties() {
@@ -7,6 +8,8 @@ export class DileToast extends LitElement {
7
8
  messages: { type: Array },
8
9
  /* Miliseconds the feedback message remains on the screen */
9
10
  duration: { type: Number },
11
+ /* If true, shows a close icon on each toast so the user can dismiss it manually */
12
+ showCloseIcon: { type: Boolean },
10
13
  };
11
14
  }
12
15
 
@@ -15,6 +18,8 @@ export class DileToast extends LitElement {
15
18
  this.messages = [];
16
19
  this.duration = 3000;
17
20
  this.cleanTimeout = false;
21
+ this.showCloseIcon = false;
22
+ this._messageId = 0;
18
23
  }
19
24
 
20
25
  static get styles() {
@@ -31,11 +36,15 @@ export class DileToast extends LitElement {
31
36
 
32
37
  render() {
33
38
  return html`
34
- ${this.messages.map(
39
+ ${repeat(
40
+ this.messages,
41
+ (msg) => msg.id,
35
42
  (msg) => html`
36
43
  <dile-toast-item
37
44
  .msg="${msg}"
45
+ .showCloseIcon="${this.showCloseIcon}"
38
46
  duration="${this.duration}"
47
+ @dile-toast-item-closed="${(e) => this._closeMessage(e.detail.id)}"
39
48
  ></dile-toast-item>
40
49
  `
41
50
  )}
@@ -51,6 +60,7 @@ export class DileToast extends LitElement {
51
60
  this.messages = [
52
61
  ...this.messages,
53
62
  {
63
+ id: ++this._messageId,
54
64
  text,
55
65
  toastType,
56
66
  hidden: false,
@@ -61,6 +71,27 @@ export class DileToast extends LitElement {
61
71
  this._programMessageHide();
62
72
  }
63
73
 
74
+ /**
75
+ * Closes a specific message before its automatic duration elapses, triggered by the user
76
+ */
77
+ _closeMessage(id) {
78
+ const message = this.messages.find((item) => item.id === id);
79
+ if (!message || message.hidden) return;
80
+ this.messages = this.messages.map((item) =>
81
+ item.id === id ? { ...item, hidden: true } : item
82
+ );
83
+ setTimeout(() => {
84
+ this.messages = this.messages.filter((item) => item.id !== id);
85
+ }, 500);
86
+ this.dispatchEvent(
87
+ new CustomEvent("dile-toast-closed-by-user", {
88
+ bubbles: true,
89
+ composed: true,
90
+ detail: { message },
91
+ })
92
+ );
93
+ }
94
+
64
95
  /**
65
96
  * Programs the hiding of the last message, after it's duration time
66
97
  */
@@ -1,9 +1,12 @@
1
1
  import { LitElement, html, css } from "lit";
2
+ import { clearIcon } from "@dile/icons/index.js";
3
+ import "../../icon/icon.js";
2
4
 
3
5
  export class DileToastItem extends LitElement {
4
6
  static get properties() {
5
7
  return {
6
8
  msg: { type: Object },
9
+ showCloseIcon: { type: Boolean },
7
10
  };
8
11
  }
9
12
 
@@ -15,6 +18,7 @@ export class DileToastItem extends LitElement {
15
18
  hidden: false,
16
19
  opening: true,
17
20
  };
21
+ this.showCloseIcon = false;
18
22
  }
19
23
 
20
24
  static get styles() {
@@ -26,6 +30,9 @@ export class DileToastItem extends LitElement {
26
30
  display: block;
27
31
  }
28
32
  div {
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: space-between;
29
36
  color: var(--dile-toast-text-color, #fff);
30
37
  padding: var(--dile-toast-padding, 10px 15px);
31
38
  margin-top: 10px;
@@ -40,6 +47,16 @@ export class DileToastItem extends LitElement {
40
47
  font-weight: var(--dile-toast-font-weight, normal);
41
48
  border-radius: var(--dile-toast-border-radius, 0);
42
49
  }
50
+ .text {
51
+ flex-grow: 1;
52
+ }
53
+ .closeicon {
54
+ cursor: pointer;
55
+ flex-shrink: 0;
56
+ margin-left: 10px;
57
+ --dile-icon-color: var(--dile-toast-close-icon-color, var(--dile-toast-text-color, #fff));
58
+ --dile-icon-size: var(--dile-toast-close-icon-size, 16px);
59
+ }
43
60
  .hidden {
44
61
  top: 20px;
45
62
  opacity: 0;
@@ -68,11 +85,28 @@ export class DileToastItem extends LitElement {
68
85
  ? "opening"
69
86
  : ""} ${this.getClassType(this.msg.toastType)}"
70
87
  >
71
- ${this.msg.text}
88
+ <span class="text">${this.msg.text}</span>
89
+ ${this.showCloseIcon
90
+ ? html`<dile-icon
91
+ .icon="${clearIcon}"
92
+ class="closeicon"
93
+ @click="${this.closeByUser}"
94
+ ></dile-icon>`
95
+ : ""}
72
96
  </div>
73
97
  `;
74
98
  }
75
99
 
100
+ closeByUser() {
101
+ this.dispatchEvent(
102
+ new CustomEvent("dile-toast-item-closed", {
103
+ bubbles: true,
104
+ composed: true,
105
+ detail: { id: this.msg.id, msg: this.msg },
106
+ })
107
+ );
108
+ }
109
+
76
110
  firstUpdated() {
77
111
  setTimeout(() => {
78
112
  this.msg.opening = false;
@@ -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.26.0",
3
+ "version": "2.27.1",
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": "90ebde5b908aab4093afeee3bda87ef3ad85904e"
30
+ "gitHead": "fec5662d02ad55763620b92b5748e45217f31b11"
30
31
  }