@jack-henry/jh-elements 2.0.0-beta.12 → 2.0.0-beta.13

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.
@@ -2,48 +2,23 @@
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
 
5
- import { html, render, css } from 'lit';
5
+ import { html } from 'lit';
6
+ import { ifDefined } from 'lit/directives/if-defined.js';
6
7
  import { JhInput } from '../input/input.js';
7
8
  import '@jack-henry/jh-icons/icons-wc/icon-eye-slash.js';
8
9
  import '@jack-henry/jh-icons/icons-wc/icon-eye.js';
9
10
 
11
+ let id = 0;
12
+
10
13
  /**
11
14
  * @slot jh-input-password-hidden - Use to insert a custom icon within the toggle password button when the input value is masked.
12
15
  * @slot jh-input-password-visible - Use to insert a custom icon within the toggle password button when the input value is unmasked.
13
16
  * @customElement jh-input-password
14
17
  */
15
18
  export class JhInputPassword extends JhInput {
16
- #inputEl;
17
-
18
- static get styles() {
19
- return [
20
- super.styles,
21
- css`
22
- .password-toggle-btn {
23
- position: absolute;
24
- right: var(--jh-dimension-400);
25
- }
26
- .clear-button {
27
- right: var(--jh-dimension-1400);
28
- }
29
- .display-clear-button input {
30
- padding-right: var(--jh-dimension-2400);
31
- }
32
- input {
33
- padding-right: var(--jh-dimension-1400);
34
- }
35
- :host([size='small']) .password-toggle-btn {
36
- top: 4px;
37
- }
38
- :host([size='medium']) .password-toggle-btn {
39
- top: 8px;
40
- }
41
- :host([size='large']) .password-toggle-btn {
42
- top: 12px;
43
- }
44
- `
45
- ];
46
- }
19
+
20
+ /** @type {?number} */
21
+ #id;
47
22
 
48
23
  static get properties() {
49
24
  return {
@@ -69,39 +44,74 @@ export class JhInputPassword extends JhInput {
69
44
  /** @type {?string} */
70
45
  this.accessibleLabelShowPassword = null;
71
46
  /** @type {boolean} */
72
- this.hideRightSlot = true;
73
- /** @type {boolean} */
74
47
  this.passwordVisible = false;
75
48
  }
76
49
 
77
- firstUpdated() {
78
- super.firstUpdated();
79
- this.#inputEl = this.shadowRoot.querySelector('input');
50
+ connectedCallback() {
51
+ super.connectedCallback();
52
+ this.#id = id++;
80
53
  }
81
54
 
82
- updated(changedProperties) {
83
- super.updated(changedProperties);
55
+ renderInput() {
56
+ let describedby;
84
57
 
85
- if (changedProperties.has('passwordVisible')) {
86
- if (this.passwordVisible) {
87
- this.#inputEl.setAttribute('type', 'text');
88
- } else {
89
- this.#inputEl.setAttribute('type', 'password');
90
- }
58
+ if (this.helperText || (this.errorText && this.invalid)) {
59
+ describedby = this._getDescribedby();
91
60
  }
92
- this.#insertTogglePasswordBtn();
61
+
62
+ const leftSlot = this.readonly ? null : this.renderLeftSlot();
63
+ const rightSlot = this.readonly ? null : this.renderRightSlot();
64
+ const clearButton = this.readonly ? null : this.renderClearButton();
65
+
66
+ return html`
67
+ <div class="input-container">
68
+ <div class="input-wrapper">
69
+ ${leftSlot}
70
+ <input
71
+ id="jh-input-${this.#id}"
72
+ aria-describedby=${describedby}
73
+ aria-invalid=${ifDefined(this.invalid ? 'true' : null)}
74
+ aria-label=${ifDefined(
75
+ this.accessibleLabel === '' ? null : this.accessibleLabel
76
+ )}
77
+ autocomplete=${ifDefined(
78
+ this.autocomplete === '' ? null : this.autocomplete
79
+ )}
80
+ ?disabled=${this.disabled}
81
+ enterkeyhint=${ifDefined(
82
+ this.enterkeyhint === '' ? null : this.enterkeyhint
83
+ )}
84
+ inputmode=${ifDefined(this.inputmode === '' ? null : this.inputmode)}
85
+ maxlength=${ifDefined(this.maxlength === '' ? null : this.maxlength)}
86
+ minlength=${ifDefined(this.minlength === '' ? null : this.minlength)}
87
+ name=${ifDefined(this.name === '' ? null : this.name)}
88
+ ?readonly=${this.readonly}
89
+ ?required=${this.required}
90
+ type=${this.passwordVisible ? 'text' : 'password'}
91
+ .value=${this.value}
92
+ @keydown=${this.inputMask ? this._handleKeydown : null}
93
+ @change=${this._handleChange}
94
+ @input=${this._handleInput}
95
+ @select=${this._handleSelect}
96
+ />
97
+ ${clearButton}
98
+ ${rightSlot}
99
+ </div>
100
+ </div>
101
+ `;
93
102
  }
94
103
 
95
- #insertTogglePasswordBtn() {
96
- let inputContainerEl = this.shadowRoot.querySelector('.input-container');
97
- let togglePasswordButton = this.#createTogglePasswordBtn();
98
- render(togglePasswordButton, inputContainerEl);
104
+ renderRightSlot() {
105
+ if (this.hideRightSlot) return;
106
+
107
+ return html`
108
+ <slot name="jh-input-right" @slotchange=${this._handleSlotChange}>${this.#renderTogglePasswordBtn()}</slot>
109
+
110
+ `;
99
111
  }
100
112
 
101
- #createTogglePasswordBtn() {
102
- if (this.readonly) {
103
- return;
104
- }
113
+ #renderTogglePasswordBtn() {
114
+ if (this.readonly) return;
105
115
 
106
116
  let accessibleLabel = this.passwordVisible
107
117
  ? this.accessibleLabelHidePassword
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
 
5
- import { css } from 'lit';
5
+ import { css, html } from 'lit';
6
6
  import { JhInput } from '../input/input.js';
7
7
  import '@jack-henry/jh-icons/icons-wc/icon-magnifying-glass.js';
8
8
 
@@ -25,17 +25,20 @@ export class JhInputSearch extends JhInput {
25
25
 
26
26
  firstUpdated() {
27
27
  super.firstUpdated();
28
- let leftSlot = this.shadowRoot.querySelector('slot[name="jh-input-left"]');
29
-
30
- // insert fallback content in left slot if empty
31
- if (leftSlot && leftSlot.assignedElements().length === 0) {
32
- this.innerHTML +=
33
- '<jh-icon-magnifying-glass slot="jh-input-left" aria-hidden="true"></jh-icon-magnifying-glass>';
34
- }
35
28
 
36
29
  // set input type to search
37
30
  let inputEl = this.shadowRoot.querySelector('input');
38
31
  inputEl.setAttribute('type', 'search');
39
32
  }
33
+
34
+ renderLeftSlot() {
35
+ if (this.hideLeftSlot) return null;
36
+
37
+ return html`
38
+ <slot name="jh-input-left" @slotchange=${this._handleSlotChange}>
39
+ <jh-icon-magnifying-glass aria-hidden="true"></jh-icon-magnifying-glass>
40
+ </slot>
41
+ `;
42
+ }
40
43
  }
41
44
  customElements.define('jh-input-search', JhInputSearch);
@@ -18,6 +18,16 @@ let id = 0;
18
18
  export class JhInputTextarea extends JhInput {
19
19
  /** @type {?number} */
20
20
  #id;
21
+ /** @type {?ResizeObserver} */
22
+ #resizeObserver;
23
+
24
+ get #textareaEl() {
25
+ return this.renderRoot?.querySelector('textarea');
26
+ }
27
+
28
+ get #footerContent() {
29
+ return this.renderRoot?.querySelector('.footer-content');
30
+ }
21
31
 
22
32
  static get styles() {
23
33
  return [
@@ -118,6 +128,7 @@ export class JhInputTextarea extends JhInput {
118
128
  );
119
129
  }
120
130
  :host([readonly]) textarea {
131
+ background-color: transparent;
121
132
  height: auto;
122
133
  border: none;
123
134
  padding-left: 0;
@@ -153,14 +164,14 @@ export class JhInputTextarea extends JhInput {
153
164
  attribute: 'auto-grow',
154
165
  },
155
166
  /** Sets the width of the input field. */
156
- cols: { type: String },
167
+ cols: { type: Number },
157
168
  /** Removes native resize capability of the input field. */
158
169
  noResize: {
159
170
  type: Boolean,
160
171
  attribute: 'no-resize',
161
172
  },
162
173
  /** Sets the height of the input field. */
163
- rows: { type: String },
174
+ rows: { type: Number },
164
175
  /** Specifies how text should be wrapped when submitted in a form. The `cols` property must be set for `wrap='hard'` to take effect. */
165
176
  wrap: { type: String },
166
177
  /** Prevents users from changing the input value. */
@@ -171,12 +182,12 @@ export class JhInputTextarea extends JhInput {
171
182
  constructor() {
172
183
  super();
173
184
  /** @type {boolean} */
174
- this.autoGrow = null;
175
- /** @type {?string} */
185
+ this.autoGrow = false;
186
+ /** @type {?number} */
176
187
  this.cols = null;
177
188
  /** @type {boolean} */
178
189
  this.noResize = true;
179
- /** @type {?string} */
190
+ /** @type {?number} */
180
191
  this.rows = null;
181
192
  /** @type {'small'|'medium'|'large'} */
182
193
  this.size = 'medium';
@@ -189,191 +200,72 @@ export class JhInputTextarea extends JhInput {
189
200
  this.#id = id++;
190
201
  }
191
202
 
192
- firstUpdated() {
193
- // add resize observer to update width of footer when textarea width changes
194
- let footerContent = this.shadowRoot.querySelector('.footer-content');
195
- let textareaEl = this.shadowRoot.querySelector('textarea');
196
-
197
- if (footerContent) {
198
- new ResizeObserver(() => {
199
- this.#updateFooterWidth(footerContent, textareaEl);
200
- }).observe(textareaEl);
203
+ disconnectedCallback() {
204
+ super.disconnectedCallback();
205
+ if (this.#resizeObserver) {
206
+ this.#resizeObserver.unobserve(this.#textareaEl);
207
+ this.#resizeObserver.disconnect();
208
+ this.#resizeObserver = null;
201
209
  }
202
210
  }
203
211
 
204
- #updateFooterWidth(footerContent, textareaEl) {
205
- footerContent.style.width = textareaEl.offsetWidth + 'px';
212
+ firstUpdated() {
213
+ // add resize observer to update width of footer when textarea width changes
214
+ if (this.#footerContent) {
215
+ this.#resizeObserver = new ResizeObserver(() => {
216
+ this.#updateFooterWidth();
217
+ });
218
+ this.#resizeObserver.observe(this.#textareaEl);
219
+ }
206
220
  }
207
221
 
208
- #dispatch(eventName, details) {
209
- this.dispatchEvent(
210
- new CustomEvent(eventName, {
211
- detail: details,
212
- bubbles: true,
213
- cancelable: true,
214
- composed: true,
215
- })
216
- );
222
+ #updateFooterWidth() {
223
+ if (this.#footerContent) {
224
+ this.#footerContent.style.width = this.#textareaEl.offsetWidth + 'px';
225
+ }
217
226
  }
218
227
 
219
- #handleInput(e) {
220
- this.value = e.target.value;
221
-
222
- this.#dispatch('jh-input', { value: this.value } );
223
-
228
+ _handleInput(e) {
229
+ super._handleInput(e);
230
+
231
+ // add textarea specific autogrow
224
232
  if (this.autoGrow) {
225
233
  this.#autoGrowTextarea();
226
234
  }
227
235
  }
228
236
 
229
237
  #autoGrowTextarea() {
230
- let textareaEl = this.shadowRoot.querySelector('textarea');
231
- textareaEl.style.height = 'auto';
232
- textareaEl.style.height = textareaEl.scrollHeight + 'px';
233
- }
234
-
235
- #handleChange() {
236
- let payload = {
237
- 'value': this.value,
238
- };
239
- this.#dispatch('jh-change', payload);
238
+ // Reset height to auto to get the correct scrollHeight
239
+ this.#textareaEl.style.height = 'auto';
240
+ // Set height to scrollHeight to accommodate content
241
+ this.#textareaEl.style.height = `${this.#textareaEl.scrollHeight}px`;
240
242
  }
241
243
 
242
- #handleSelect(e) {
243
- const selectedString = e.target.value.substring(
244
- e.target.selectionStart,
245
- e.target.selectionEnd
246
- );
247
-
248
- if (selectedString) {
249
- this.#dispatch('jh-select', {
250
- selected: selectedString,
251
- selectionStart: e.target.selectionStart,
252
- selectionEnd: e.target.selectionEnd,
253
- });
254
- }
255
- }
256
-
257
- #handleMaxlength() {
258
- this.#dispatch('jh-maxlength');
259
- }
260
-
261
- #getDescribedby() {
262
- let describedbyString = '';
263
-
264
- if (this.errorText) {
265
- describedbyString += `jh-input-error-${this.#id}`;
266
- }
267
- if (this.helperText) {
268
- describedbyString += ` jh-input-helper-${this.#id}`;
269
- }
270
- if (this.showCharCount) {
271
- describedbyString += ` jh-input-counter-${this.#id}`;
272
- }
273
- return describedbyString;
274
- }
275
-
276
- render() {
277
- let label;
278
- let indicator;
279
- let helperText;
280
- let input;
281
- let footer;
282
- let errorText;
283
- let charCount;
284
- let describedby;
285
-
286
- if (this.label) {
287
- if (this.showIndicator) {
288
- if (this.required) {
289
- indicator = html`<span class="indicator" aria-hidden="true"> *</span>`;
290
- } else {
291
- indicator = html`<span class="indicator"> (optional)</span>`;
292
- }
293
- }
294
-
295
- if (this.helperText) {
296
- helperText = html`
297
- <p id="jh-input-helper-${this.#id}" class="helper-text">
298
- ${this.helperText}
299
- </p>
300
- `;
301
- }
302
-
303
- label = html`
304
- <label for="jh-input-${this.#id}">${this.label}${indicator}</label>
305
- ${helperText}
306
- `;
307
- }
308
-
309
- if (this.showCharCount) {
310
- let valueLength = this.value ? this.value.length : 0;
311
- let charCountValue = `${this.maxlength ? `${valueLength}/${this.maxlength}` : valueLength}`;
312
-
313
- if (valueLength && valueLength === Number(this.maxlength)) {
314
- this.#handleMaxlength();
315
- }
316
-
317
- charCount = html`
318
- <p class="counter" aria-hidden="true">${charCountValue}</p>
319
- `;
320
- }
321
-
322
- if (this.invalid && this.errorText) {
323
- errorText = html`
324
- <p id="jh-input-error-${this.#id}" class="error-text">
325
- ${this.errorText}
326
- </p>
327
- `;
328
- }
329
-
330
- if ((this.invalid && this.errorText) || this.showCharCount) {
331
- footer = html`
332
- <div class="footer-content">
333
- ${errorText}
334
- ${charCount}
335
- </div>
336
- `;
337
- }
338
-
339
- if (helperText || errorText || charCount) {
340
- describedby = this.#getDescribedby();
341
- }
342
-
343
- input = html`
344
- <textarea
345
- id="jh-input-${this.#id}"
346
- aria-describedby=${describedby}
347
- aria-invalid=${ifDefined(this.invalid ? 'true' : null)}
348
- aria-label=${ifDefined(
349
- this.accessibleLabel === '' ? null : this.accessibleLabel
350
- )}
351
- autocomplete=${ifDefined(
352
- this.autocomplete === '' ? null : this.autocomplete
353
- )}
354
- cols=${ifDefined(this.cols === '' ? null : this.cols)}
355
- ?disabled=${this.disabled}
356
- enterkeyhint=${ifDefined(
357
- this.enterkeyhint === '' ? null : this.enterkeyhint
358
- )}
359
- inputmode=${ifDefined(this.inputmode === '' ? null : this.inputmode)}
360
- maxlength=${ifDefined(this.maxlength === '' ? null : this.maxlength)}
361
- minlength=${ifDefined(this.minlength === '' ? null : this.minlength)}
362
- name=${ifDefined(this.name === '' ? null : this.name)}
363
- ?readonly=${this.readonly}
364
- ?required=${this.required}
365
- rows=${ifDefined(this.rows === '' ? null : this.rows)}
366
- .value=${this.value}
367
- wrap=${ifDefined(this.wrap === '' ? null : this.wrap)}
368
- @change=${this.#handleChange}
369
- @select=${this.#handleSelect}
370
- @input=${this.#handleInput}
371
- ></textarea>
372
- `;
373
-
244
+ renderInput() {
374
245
  return html`
375
- ${label} ${input} ${footer}
376
- `;
246
+ <textarea
247
+ id="jh-input-${this.#id}"
248
+ aria-describedby=${this._getDescribedby()}
249
+ aria-invalid=${ifDefined(this.invalid ? 'true' : null)}
250
+ aria-label=${ifDefined(this.accessibleLabel === '' ? null : this.accessibleLabel)}
251
+ autocomplete=${ifDefined(this.autocomplete === '' ? null : this.autocomplete)}
252
+ cols=${ifDefined(this.cols ? Number(this.cols) : null)}
253
+ ?disabled=${this.disabled}
254
+ enterkeyhint=${ifDefined(this.enterkeyhint || null)}
255
+ inputmode=${ifDefined(this.inputmode || null)}
256
+ maxlength=${ifDefined(this.maxlength ? Number(this.maxlength) : null)}
257
+ minlength=${ifDefined(this.minlength ? Number(this.minlength) : null)}
258
+ name=${ifDefined(this.name || null)}
259
+ ?readonly=${this.readonly}
260
+ ?required=${this.required}
261
+ rows=${ifDefined(this.rows ? Number(this.rows) : null)}
262
+ .value=${this.value}
263
+ wrap=${ifDefined(this.wrap || null)}
264
+ @change=${this._handleChange}
265
+ @input=${this._handleInput}
266
+ @select=${this._handleSelect}
267
+ ></textarea>
268
+ `
377
269
  }
378
270
  }
379
- customElements.define('jh-input-textarea', JhInputTextarea);
271
+ customElements.define('jh-input-textarea', JhInputTextarea);
@@ -120,7 +120,10 @@ export class JhListItem extends LitElement {
120
120
  border-left-width: var(--jh-border-selected-width);
121
121
  }
122
122
  :host([tabindex][selected]) .list-item {
123
- padding-left: var(--jh-dimension-400);
123
+ padding-left: calc(var(
124
+ --jh-list-item-space-padding-left,
125
+ var(--jh-dimension-600)
126
+ ) - var(--jh-border-selected-width));
124
127
  }
125
128
  :host([tabindex][disabled]) {
126
129
  opacity: var(--jh-opacity-disabled);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Jack Henry
3
+ *
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ // TODO for tagged multi-select we will need to filter out already selected items from the list of options.
8
+
9
+ // returns a filtered list of terms that include the search term in the specified key (default is 'label')
10
+ export const JhFilter = {
11
+ filterList(items, searchTerm, key = 'label') {
12
+ if (!items || items.length === 0) {
13
+ return [];
14
+ }
15
+ if (!searchTerm) {
16
+ return items;
17
+ }
18
+ const lowerSearchTerm = searchTerm.toLowerCase();
19
+ return items.filter((item) =>
20
+ String(item[key] || '')
21
+ .toLowerCase()
22
+ .includes(lowerSearchTerm),
23
+ );
24
+ },
25
+
26
+ // returns the first term that includes the search term in the specified key (default is 'label'), or null if not found.
27
+ // Skip disabled items in the inline suggestions as they are not selectable.
28
+ filterInline(items, searchTerm, key = 'label') {
29
+ if (!searchTerm || !items || items.length === 0) {
30
+ return null;
31
+ }
32
+ const lowerSearchTerm = searchTerm.toLowerCase();
33
+ return (
34
+ items.find(
35
+ (item) =>
36
+ !item.disabled &&
37
+ String(item[key] || '')
38
+ .toLowerCase()
39
+ .startsWith(lowerSearchTerm),
40
+ ) || null
41
+ );
42
+ },
43
+ //returns the index of the first term that starts with the buffer, or -1 if not found
44
+ // exclude disabled items from the search as they are not selectable.
45
+ // when it is a multi-select, return selected items as well, so users can deselect easily.
46
+
47
+ jumpAhead(items, buffer, activeIndex, key = 'label') {
48
+ if (!buffer || !items || items.length === 0) {
49
+ return -1;
50
+ }
51
+ // more than one character that are same.
52
+ const allCharsSame = (str) => {
53
+ if (str.length <= 1) return false;
54
+ return str.split('').every((char) => char === str[0]);
55
+ };
56
+
57
+ const safeActiveIndex = activeIndex ?? -1;
58
+ const lowerBuffer = buffer.toLowerCase();
59
+ const char = lowerBuffer[0];
60
+ const isCycling = allCharsSame(lowerBuffer);
61
+
62
+ if (isCycling) {
63
+ // Find the next item that starts with the character after the active index
64
+ const nextIndex = items.findIndex(
65
+ (item, index) =>
66
+ index > safeActiveIndex &&
67
+ !item.disabled &&
68
+ String(item[key] || '')
69
+ .toLowerCase()
70
+ .startsWith(char),
71
+ );
72
+ if (nextIndex !== -1) {
73
+ return nextIndex;
74
+ }
75
+ // If not found, wrap around and search from the beginning up to the active index
76
+ return items.findIndex(
77
+ (item) =>
78
+ !item.disabled &&
79
+ String(item[key] || '')
80
+ .toLowerCase()
81
+ .startsWith(char),
82
+ );
83
+ }
84
+ return items.findIndex(
85
+ (item) =>
86
+ !item.disabled &&
87
+ String(item[key] || '')
88
+ .toLowerCase()
89
+ .startsWith(lowerBuffer),
90
+ );
91
+ },
92
+ };