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

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
@@ -110,7 +120,7 @@ export class JhInputPassword extends JhInput {
110
120
  let passwordBtn = html`
111
121
  <jh-button
112
122
  class="password-toggle-btn"
113
- size="small"
123
+ size="x-small"
114
124
  appearance="tertiary"
115
125
  ?disabled=${this.disabled}
116
126
  accessible-label=${accessibleLabel}
@@ -120,24 +130,24 @@ export class JhInputPassword extends JhInput {
120
130
  ? html`
121
131
  <slot
122
132
  name="jh-input-password-visible"
123
- slot="jh-button-icon"
133
+ slot="jh-button-icon-left"
124
134
  >
125
135
  <jh-icon-eye-slash
126
- slot="jh-button-icon"
136
+ slot="jh-button-icon-left"
127
137
  aria-hidden="true"
128
- size="medium"
138
+ size="x-small"
129
139
  ></jh-icon-eye-slash>
130
140
  </slot>
131
141
  `
132
142
  : html`
133
143
  <slot
134
144
  name="jh-input-password-hidden"
135
- slot="jh-button-icon"
145
+ slot="jh-button-icon-left"
136
146
  >
137
147
  <jh-icon-eye
138
- slot="jh-button-icon"
148
+ slot="jh-button-icon-left"
139
149
  aria-hidden="true"
140
- size="medium"
150
+ size="x-small"
141
151
  ></jh-icon-eye>
142
152
  </slot>
143
153
  `}
@@ -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" size="x-small"></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);
@@ -136,7 +136,7 @@ export class JhNotification extends LitElement {
136
136
  }
137
137
  /* Default slot styling */
138
138
  .display-default-slot {
139
- padding: var(--jh-dimension-200) 0;
139
+ padding: var(--jh-dimension-100) 0;
140
140
  margin: var(--jh-dimension-50) 0;
141
141
  font: var(--jh-font-body-regular-1);
142
142
  display: flex;
@@ -149,7 +149,7 @@ export class JhNotification extends LitElement {
149
149
  }
150
150
  slot[name="jh-notification-icon"]::slotted(*) {
151
151
  margin-right: var(--jh-dimension-400);
152
- padding: var(--jh-dimension-200) 0;
152
+ padding: var(--jh-dimension-100) 0;
153
153
  }
154
154
  :host([appearance='neutral']) slot[name="jh-notification-icon"] {
155
155
  --jh-icon-color-fill: var(--jh-notification-icon-color-fill-neutral, var(--jh-color-content-on-primary-enabled));
@@ -204,7 +204,8 @@ export class JhNotification extends LitElement {
204
204
  .stacked-container,
205
205
  .inline-container {
206
206
  display: flex;
207
- align-items: flex-start;
207
+ align-items: center;
208
+ line-height: 0;
208
209
  }
209
210
  `;
210
211
  }
@@ -295,6 +296,13 @@ export class JhNotification extends LitElement {
295
296
  }
296
297
  }
297
298
 
299
+ #handleIconSlotChange(e) {
300
+ let newSlottedElement = e.target.assignedElements()[0];
301
+ if (newSlottedElement?.tagName.startsWith('JH-ICON')) {
302
+ newSlottedElement.setAttribute('size', 'medium');
303
+ }
304
+ }
305
+
298
306
  render() {
299
307
  let dismissBtn;
300
308
 
@@ -305,8 +313,8 @@ export class JhNotification extends LitElement {
305
313
  appearance="secondary"
306
314
  accessible-label=${this.dismissButtonAccessibleLabel}
307
315
  @click=${this.#handleDismissal}
308
- ><slot name="jh-notification-dismiss-icon" slot="jh-button-icon">
309
- <jh-icon-xmark slot="jh-button-icon"></jh-icon-xmark>
316
+ ><slot name="jh-notification-dismiss-icon" slot="jh-button-icon-left">
317
+ <jh-icon-xmark slot="jh-button-icon-left"></jh-icon-xmark>
310
318
  </slot>
311
319
  </jh-button>
312
320
  `;
@@ -314,7 +322,7 @@ export class JhNotification extends LitElement {
314
322
 
315
323
  return html`
316
324
  <div class=${this.stacked ? 'stacked-container' : 'inline-container'}>
317
- <slot aria-hidden="true" name="jh-notification-icon"></slot>
325
+ <slot aria-hidden="true" name="jh-notification-icon" @slotchange=${this.#handleIconSlotChange}></slot>
318
326
  <slot @slotchange=${this.#handleSlotChange}></slot>
319
327
  ${this.stacked ? null : this.#getActionButtons()}
320
328
  ${dismissBtn}
@@ -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
+ };