@jack-henry/jh-elements 2.0.0-beta.11 → 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.
- package/components/checkbox-group/checkbox-group.js +16 -4
- package/components/input/input.js +313 -252
- package/components/input-email/input-email.js +3 -4
- package/components/input-password/input-password.js +64 -54
- package/components/input-search/input-search.js +11 -8
- package/components/input-textarea/input-textarea.js +67 -175
- package/components/list-item/list-item.js +4 -1
- package/components/menu/menu.js +12 -1
- package/components/radio-group/radio-group.js +61 -6
- package/components/select/filtering.js +92 -0
- package/components/select/select.js +660 -0
- package/components/toast/toast.js +1 -5
- package/components/tooltip/tooltip.js +46 -65
- package/custom-elements.json +820 -183
- package/index.d.ts +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Jack Henry
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { css, html } from 'lit';
|
|
8
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
9
|
+
import { JhInput } from '../input/input.js';
|
|
10
|
+
import '../menu/menu.js';
|
|
11
|
+
import '../list-item/list-item.js';
|
|
12
|
+
import '../list-group/list-group.js';
|
|
13
|
+
import '@jack-henry/jh-icons/icons-wc/icon-chevron-up-small.js';
|
|
14
|
+
import '@jack-henry/jh-icons/icons-wc/icon-chevron-down-small.js';
|
|
15
|
+
import { JhFilter } from './filtering.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Select
|
|
19
|
+
* @customElement jh-select
|
|
20
|
+
*
|
|
21
|
+
* @cssprop --jh-select-input-field-border-radius - The input field border radius. Defaults to `--jh-border-radius-100`.
|
|
22
|
+
* @cssprop --jh-select-input-field-color-background - The input field background-color. Defaults to `--jh-color-container-primary-enabled`.
|
|
23
|
+
* @cssprop --jh-select-icon-color-fill - The select icons color. Defaults to `--jh-color-content-secondary-enabled`.
|
|
24
|
+
* @cssprop --jh-select-menu-z-index - The menu z-index. Defaults to `--jh-z-index-positive-1000`.
|
|
25
|
+
* @cssprop --jh-select-menu-border-radius - The menu border-radius. Defaults to `--jh-border-radius-200`.
|
|
26
|
+
* @cssprop --jh-select-menu-shadow - The menu box-shadow. Defaults to `--jh-shadow-high`.
|
|
27
|
+
* @cssprop --jh-select-menu-color-background - The menu container background-color. Defaults to `--jh-color-container-primary-enabled`.
|
|
28
|
+
* @cssprop --jh-select-menu-space-padding - The menu container padding. Defaults to `--jh-dimension-200 0`.
|
|
29
|
+
* @cssprop --jh-select-menu-size-max-width - The menu maximum width. Defaults to `none`.
|
|
30
|
+
* @cssprop --jh-select-menu-size-min-width - The menu minimum width. Defaults to `none`.
|
|
31
|
+
* @cssprop --jh-select-menu-size-max-height - The menu maximum height. Defaults to `480px`.
|
|
32
|
+
* @cssprop --jh-select-input-field-color-border-error - The input field border-color when invalid. Defaults to `--jh-border-error-color`.
|
|
33
|
+
* @cssprop --jh-select-label-color-text - The label text color. Defaults to `--jh-color-content-primary-enabled`.
|
|
34
|
+
* @cssprop --jh-select-helper-color-text - The helper-text text color. Defaults to `jh-color-content-secondary-enabled`.
|
|
35
|
+
* @cssprop --jh-select-required-color-text - The required indicator color. Defaults to `jh-color-content-negative-enabled`.
|
|
36
|
+
* @cssprop --jh-select-optional-color-text - The optional indicator text color. Defaults to `jh-color-content-primary-enabled`.
|
|
37
|
+
* @cssprop --jh-select-value-color-text - The value text color. Defaults to `jh-color-content-primary-enabled`.
|
|
38
|
+
* @cssprop --jh-select-input-field-color-border-enabled - The input field border-color. Defaults to `--jh-border-control-color`.
|
|
39
|
+
* @cssprop --jh-select-input-field-color-border-focus - The input field border-color when in focus. Defaults to `--jh-color-content-brand-hover`.
|
|
40
|
+
* @cssprop --jh-select-input-color-focus - The input field outline when it receives keyboard focus. Defaults to `--jh-border-focus-color`.
|
|
41
|
+
* @cssprop --jh-select-input-field-color-border-hover - The input field border-color when hovered. Defaults to `--jh-color-content-brand-hover`.
|
|
42
|
+
* @cssprop --jh-select-input-field-color-border-active - The input field border-color when active. Defaults to `--jh-color-content-brand-active`.
|
|
43
|
+
* @cssprop --jh-select-input-field-color-border-disabled - The input field border-color when disabled. Defaults to `--jh-border-control-color`.
|
|
44
|
+
* @cssprop --jh-select-opacity-disabled - The select opacity when disabled. Defaults to `--jh-opacity-disabled`.
|
|
45
|
+
* @cssprop --jh-select-error-color-text - The error message text color. Defaults to `jh-color-content-negative-enabled`.
|
|
46
|
+
* @cssprop --jh-select-item-size-height - The list item height. Defaults to `auto`.
|
|
47
|
+
* @cssprop --jh-select-item-space-padding-right - The list item right padding. Defaults to `--jh-dimension-600`.
|
|
48
|
+
* @cssprop --jh-select-item-space-padding-left - The list item left padding. Defaults to `--jh-dimension-600`.
|
|
49
|
+
* @cssprop --jh-select-item-color-text - The list item text color. Defaults to `--jh-color-content-primary-enabled`.
|
|
50
|
+
* @cssprop --jh-select-item-color-background - The list item background color. Defaults to `transparent`.
|
|
51
|
+
* @cssprop --jh-select-item-color-background-focus - The list item background color when focused. Defaults to `--jh-color-container-primary-hover`.
|
|
52
|
+
* @cssprop --jh-select-item-color-focus - The list item outline color when focused. Defaults to `--jh-border-focus-color`.
|
|
53
|
+
* @cssprop --jh-select-item-color-background-hover - The list item background color when hovered. Defaults to `--jh-color-container-primary-hover`.
|
|
54
|
+
* @cssprop --jh-select-item-color-background-active - The list item background color when active. Defaults to `--jh-color-container-primary-active`.
|
|
55
|
+
* @cssprop --jh-select-item-color-background-selected - The list item background color when selected. Defaults to `--jh-color-container-primary-selected`.
|
|
56
|
+
* @cssprop --jh-select-item-color-border-selected - The list item border color when selected. Defaults to `--jh-border-selected-color`.
|
|
57
|
+
* @cssprop --jh-select-item-space-padding-left-indent - The additional left padding for grouped list items. Defaults to `--jh-dimension-200`.
|
|
58
|
+
*
|
|
59
|
+
* @slot jh-select-trigger-left - Use to insert an element such as an icon on the left side of the select input field.
|
|
60
|
+
* @slot jh-select-trigger-open - Use to replace the default chevron icon displayed when the select menu is open.
|
|
61
|
+
* @slot jh-select-trigger-closed - Use to replace the default chevron icon displayed when the select menu is closed.
|
|
62
|
+
*
|
|
63
|
+
* @event jh-change - Dispatched when the selected value changes.
|
|
64
|
+
*/
|
|
65
|
+
export class JhSelect extends JhInput {
|
|
66
|
+
/** @type {?string} */
|
|
67
|
+
#displayValue = null;
|
|
68
|
+
/** @type {string} */
|
|
69
|
+
#buffer = '';
|
|
70
|
+
/** @type {?number} */
|
|
71
|
+
#timer = null;
|
|
72
|
+
/** @type {?number} */
|
|
73
|
+
#activeIndex = null;
|
|
74
|
+
/** @type {boolean} */
|
|
75
|
+
#open = false;
|
|
76
|
+
/** @type {Array} All options flattened — source of truth, rebuilt only when options change */
|
|
77
|
+
#allOptions = [];
|
|
78
|
+
/** @type {Array} Currently visible/navigable options — same as #allOptions until search is added */
|
|
79
|
+
#flatOptions = [];
|
|
80
|
+
/** @type {(e: Event) => void} */
|
|
81
|
+
#boundDocumentClick;
|
|
82
|
+
/** @type {(e: Event) => void} */
|
|
83
|
+
#boundDocumentScroll;
|
|
84
|
+
|
|
85
|
+
get #inputWrapper() {
|
|
86
|
+
return this.renderRoot?.querySelector('.input-wrapper');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get #menuContainer() {
|
|
90
|
+
return this.renderRoot?.querySelector('.menu-container');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static get styles() {
|
|
94
|
+
return [
|
|
95
|
+
super.styles,
|
|
96
|
+
css`
|
|
97
|
+
:host {
|
|
98
|
+
--jh-input-field-border-radius: var(--jh-select-input-field-border-radius);
|
|
99
|
+
--jh-input-field-color-background: var(--jh-select-input-field-color-background);
|
|
100
|
+
--jh-icon-color-fill: var(--jh-select-icon-color-fill);
|
|
101
|
+
--jh-menu-z-index: var(--jh-select-menu-z-index);
|
|
102
|
+
--jh-menu-border-radius: var(--jh-select-menu-border-radius);
|
|
103
|
+
--jh-menu-shadow: var(--jh-select-menu-shadow);
|
|
104
|
+
--jh-menu-color-background: var(--jh-select-menu-color-background);
|
|
105
|
+
--jh-menu-space-padding: var(--jh-select-menu-space-padding);
|
|
106
|
+
--jh-input-field-color-border-error: var(--jh-select-input-field-color-border-error);
|
|
107
|
+
--jh-input-label-color-text: var(--jh-select-label-color-text);
|
|
108
|
+
--jh-input-helper-color-text: var(--jh-select-helper-color-text);
|
|
109
|
+
--jh-input-required-color-text: var(--jh-select-required-color-text);
|
|
110
|
+
--jh-input-optional-color-text: var(--jh-select-optional-color-text);
|
|
111
|
+
--jh-input-value-color-text: var(--jh-select-value-color-text);
|
|
112
|
+
--jh-input-field-color-border-enabled: var(--jh-select-input-field-color-border-enabled);
|
|
113
|
+
--jh-input-field-color-border-focus: var(--jh-select-input-field-color-border-focus);
|
|
114
|
+
--jh-input-color-focus: var(--jh-select-input-color-focus);
|
|
115
|
+
--jh-input-field-color-border-hover: var(--jh-select-input-field-color-border-hover);
|
|
116
|
+
--jh-input-field-color-border-active: var(--jh-select-input-field-color-border-active);
|
|
117
|
+
--jh-input-field-color-border-disabled: var(
|
|
118
|
+
--jh-select-input-field-color-border-disabled
|
|
119
|
+
);
|
|
120
|
+
--jh-input-opacity-disabled: var(--jh-select-opacity-disabled);
|
|
121
|
+
--jh-input-error-color-text: var(--jh-select-error-color-text);
|
|
122
|
+
--jh-list-item-size-height: var(--jh-select-item-size-height);
|
|
123
|
+
--jh-list-item-space-padding-right: var(--jh-select-item-space-padding-right);
|
|
124
|
+
--jh-list-item-space-padding-left: var(--jh-select-item-space-padding-left);
|
|
125
|
+
--jh-list-item-color-text: var(--jh-select-item-color-text);
|
|
126
|
+
--jh-list-item-color-background: var(--jh-select-item-color-background);
|
|
127
|
+
--jh-list-item-color-background-focus: var(--jh-select-item-color-background-focus);
|
|
128
|
+
--jh-list-item-color-focus: var(--jh-select-item-color-focus);
|
|
129
|
+
--jh-list-item-color-background-hover: var(--jh-select-item-color-background-hover);
|
|
130
|
+
--jh-list-item-color-background-active: var(--jh-select-item-color-background-active);
|
|
131
|
+
--jh-list-item-color-background-selected: var(--jh-select-item-color-background-selected);
|
|
132
|
+
--jh-list-item-color-border-selected: var(--jh-select-item-color-border-selected);
|
|
133
|
+
--jh-select-item-space-padding-left-indent: var(--jh-dimension-200);
|
|
134
|
+
display: block;
|
|
135
|
+
position: relative;
|
|
136
|
+
width: 100%;
|
|
137
|
+
}
|
|
138
|
+
.menu-container {
|
|
139
|
+
max-width: var(--jh-select-menu-size-max-width, none);
|
|
140
|
+
min-width: var(--jh-select-menu-size-min-width, none);
|
|
141
|
+
box-sizing: border-box;
|
|
142
|
+
overflow: visible;
|
|
143
|
+
position: absolute;
|
|
144
|
+
visibility: hidden;
|
|
145
|
+
opacity: 0;
|
|
146
|
+
width: 100%;
|
|
147
|
+
}
|
|
148
|
+
.menu-container.show {
|
|
149
|
+
visibility: visible;
|
|
150
|
+
opacity: 1;
|
|
151
|
+
}
|
|
152
|
+
input::selection {
|
|
153
|
+
background-color: transparent;
|
|
154
|
+
}
|
|
155
|
+
jh-menu {
|
|
156
|
+
max-height: var(--jh-select-menu-size-max-height, 480px);
|
|
157
|
+
overscroll-behavior: contain;
|
|
158
|
+
}
|
|
159
|
+
/* fallback values added otherwise calc would fail if the custom properties are not set. */
|
|
160
|
+
jh-list-group > jh-list-item {
|
|
161
|
+
--jh-list-item-space-padding-left: calc(
|
|
162
|
+
var(--jh-select-item-space-padding-left, var(--jh-dimension-600)) +
|
|
163
|
+
var(--jh-select-item-space-padding-left-indent, var(--jh-dimension-200))
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
/* remove list item focus styles so active class styles are applied correctly */
|
|
167
|
+
jh-list-item:focus-visible {
|
|
168
|
+
background-color: inherit;
|
|
169
|
+
outline: none;
|
|
170
|
+
}
|
|
171
|
+
jh-list-item:not([selected]):focus-visible {
|
|
172
|
+
background-color: inherit;
|
|
173
|
+
}
|
|
174
|
+
jh-list-item.is-active {
|
|
175
|
+
background-color: var(
|
|
176
|
+
--jh-list-item-color-background-focus,
|
|
177
|
+
var(--jh-color-container-primary-hover)
|
|
178
|
+
);
|
|
179
|
+
outline-color: var(--jh-list-item-color-focus, var(--jh-border-focus-color));
|
|
180
|
+
outline-style: var(--jh-border-focus-style);
|
|
181
|
+
outline-width: var(--jh-border-focus-width);
|
|
182
|
+
outline-offset: -2px;
|
|
183
|
+
}
|
|
184
|
+
jh-list-item[selected].is-active {
|
|
185
|
+
background-color: var(
|
|
186
|
+
--jh-list-item-color-background-selected,
|
|
187
|
+
var(--jh-color-container-primary-selected)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
`,
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
static get properties() {
|
|
195
|
+
return {
|
|
196
|
+
/** Sets the position of the dropdown menu relative to the input field. The menu automatically flips when there is insufficient space unless `flip-disabled` is set. */
|
|
197
|
+
menuPosition: { type: String, reflect: true, attribute: 'menu-position' },
|
|
198
|
+
/** Sets the list of options to display in the dropdown menu. Accepts an array of flat options or grouped options. See documentation for the expected data format. */
|
|
199
|
+
options: { type: Array, attribute: false },
|
|
200
|
+
/** Prevents the dropdown menu from automatically flipping its position when there is insufficient viewport space. */
|
|
201
|
+
flipDisabled: { type: Boolean, attribute: 'flip-disabled' },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
constructor() {
|
|
206
|
+
super();
|
|
207
|
+
/** @type {string} */
|
|
208
|
+
this.menuPosition = 'bottom';
|
|
209
|
+
/** @type {Array} */
|
|
210
|
+
this.options = [];
|
|
211
|
+
/** @type {boolean} */
|
|
212
|
+
this.flipDisabled = false;
|
|
213
|
+
this.addEventListener('keydown', this.#handleKeydown);
|
|
214
|
+
this.#boundDocumentClick = this.#handleDocumentClick.bind(this);
|
|
215
|
+
this.#boundDocumentScroll = this.#handleDocumentScroll.bind(this);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
disconnectedCallback() {
|
|
219
|
+
super.disconnectedCallback();
|
|
220
|
+
this.removeEventListener('keydown', this.#handleKeydown);
|
|
221
|
+
document.removeEventListener('click', this.#boundDocumentClick, true);
|
|
222
|
+
document.removeEventListener('scroll', this.#boundDocumentScroll, true);
|
|
223
|
+
clearTimeout(this.#timer);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
willUpdate(changedProperties) {
|
|
227
|
+
if (changedProperties.has('options')) {
|
|
228
|
+
if (!this.options) {
|
|
229
|
+
this.#allOptions = [];
|
|
230
|
+
this.#flatOptions = [];
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.#allOptions = this.options.flatMap((item) => {
|
|
234
|
+
if (item.groupValues) {
|
|
235
|
+
return item.groupValues.map((subItem) => ({
|
|
236
|
+
label: subItem.label != null ? subItem.label : String(subItem.value),
|
|
237
|
+
...subItem,
|
|
238
|
+
groupLabel: item.groupLabel,
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
label: item.label != null ? item.label : String(item.value),
|
|
243
|
+
...item,
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
// These will be the same until we add search functionality, at which point #flatOptions will be the filtered list and #allOptions will remain the source of truth.
|
|
247
|
+
this.#flatOptions = this.#allOptions;
|
|
248
|
+
this.#activeIndex = null;
|
|
249
|
+
|
|
250
|
+
// Set initial value from the selected flag in the data array
|
|
251
|
+
const selectedOption = this.#flatOptions.find((opt) => opt.selected);
|
|
252
|
+
if (selectedOption && !this.value) {
|
|
253
|
+
this.value = String(selectedOption.value);
|
|
254
|
+
this.#displayValue = selectedOption.label;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#getIndexFromId(elementId) {
|
|
260
|
+
const parts = elementId?.split('-');
|
|
261
|
+
const index = Number(parts?.[parts.length - 1]);
|
|
262
|
+
return Number.isNaN(index) ? null : index;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async #scrollToActiveItem() {
|
|
266
|
+
await this.updateComplete;
|
|
267
|
+
const el = this.shadowRoot.getElementById(
|
|
268
|
+
`jh-select-option-${this.uniqueId}-${this.#activeIndex}`);
|
|
269
|
+
if (!el) return;
|
|
270
|
+
|
|
271
|
+
const menu = this.shadowRoot.querySelector('jh-menu');
|
|
272
|
+
if (!menu) return;
|
|
273
|
+
const scrollContainer = menu.shadowRoot?.querySelector('.menu-content') ?? menu;
|
|
274
|
+
const menuRect = scrollContainer.getBoundingClientRect();
|
|
275
|
+
const elRect = el.getBoundingClientRect();
|
|
276
|
+
// account for vertical padding on the menu
|
|
277
|
+
const menuStyles = getComputedStyle(scrollContainer);
|
|
278
|
+
const paddingTop = parseFloat(menuStyles.paddingTop);
|
|
279
|
+
const paddingBottom = parseFloat(menuStyles.paddingBottom);
|
|
280
|
+
|
|
281
|
+
const visibleTop = menuRect.top + paddingTop;
|
|
282
|
+
const visibleBottom = menuRect.bottom - paddingBottom;
|
|
283
|
+
|
|
284
|
+
if (elRect.bottom > visibleBottom) {
|
|
285
|
+
scrollContainer.scrollTop += elRect.bottom - visibleBottom;
|
|
286
|
+
} else if (elRect.top < visibleTop) {
|
|
287
|
+
scrollContainer.scrollTop -= visibleTop - elRect.top;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#handleDocumentClick(e) {
|
|
292
|
+
if (!e.composedPath().includes(this)) {
|
|
293
|
+
this.#handleCloseSelect();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
#handleDocumentScroll(e) {
|
|
297
|
+
if (!e.composedPath().includes(this)) {
|
|
298
|
+
this.#handleCloseSelect();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#handleOpenSelect() {
|
|
303
|
+
if (this.disabled || this.readonly || !this.#flatOptions.length) return;
|
|
304
|
+
if (!this.#inputWrapper || !this.#menuContainer) return;
|
|
305
|
+
|
|
306
|
+
this.#flipMenu();
|
|
307
|
+
this.#open = true;
|
|
308
|
+
document.addEventListener('click', this.#boundDocumentClick, true);
|
|
309
|
+
// Delay adding scroll listener so the menu's own layout change doesn't trigger it
|
|
310
|
+
requestAnimationFrame(() => {
|
|
311
|
+
document.addEventListener('scroll', this.#boundDocumentScroll, true);
|
|
312
|
+
});
|
|
313
|
+
// Set initial active to selected item or first item
|
|
314
|
+
if (this.#activeIndex === null) {
|
|
315
|
+
const selectedIdx = this.#flatOptions.findIndex(
|
|
316
|
+
(opt) => String(opt.value) === String(this.value));
|
|
317
|
+
this.#setActiveItem(selectedIdx !== -1 ? selectedIdx : 0);
|
|
318
|
+
}
|
|
319
|
+
this.requestUpdate();
|
|
320
|
+
}
|
|
321
|
+
#handleCloseSelect() {
|
|
322
|
+
this.#open = false;
|
|
323
|
+
this.#buffer = '';
|
|
324
|
+
clearTimeout(this.#timer);
|
|
325
|
+
if (this.#menuContainer) {
|
|
326
|
+
this.#menuContainer.style.top = '';
|
|
327
|
+
this.#menuContainer.style.bottom = '';
|
|
328
|
+
}
|
|
329
|
+
document.removeEventListener('click', this.#boundDocumentClick, true);
|
|
330
|
+
document.removeEventListener('scroll', this.#boundDocumentScroll, true);
|
|
331
|
+
this.#activeIndex = null;
|
|
332
|
+
this.requestUpdate();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#setActiveItem(index) {
|
|
336
|
+
const options = this.#flatOptions;
|
|
337
|
+
if (!options.length) return;
|
|
338
|
+
|
|
339
|
+
// Stop at bounds, don't wrap around
|
|
340
|
+
if (index < 0) index = 0;
|
|
341
|
+
if (index >= options.length) index = options.length - 1;
|
|
342
|
+
|
|
343
|
+
// Skip disabled items
|
|
344
|
+
const start = index;
|
|
345
|
+
const direction = index >= (this.#activeIndex ?? 0) ? 1 : -1;
|
|
346
|
+
while (options[index]?.disabled) {
|
|
347
|
+
index += direction;
|
|
348
|
+
if (index < 0 || index >= options.length) {
|
|
349
|
+
return; // no more options in this direction
|
|
350
|
+
}
|
|
351
|
+
if (index === start) return; // all disabled
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.#activeIndex = index;
|
|
355
|
+
this.requestUpdate();
|
|
356
|
+
this.#scrollToActiveItem();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#handleKeydown(e) {
|
|
360
|
+
if (e.ctrlKey || e.metaKey) return;
|
|
361
|
+
|
|
362
|
+
switch (e.key) {
|
|
363
|
+
case 'ArrowDown':
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
if (!this.#open) {
|
|
366
|
+
this.#handleOpenSelect();
|
|
367
|
+
} else {
|
|
368
|
+
this.#setActiveItem(this.#activeIndex === null ? 0 : this.#activeIndex + 1);
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
|
|
372
|
+
case 'ArrowUp':
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
if (!this.#open) {
|
|
375
|
+
this.#handleOpenSelect();
|
|
376
|
+
} else {
|
|
377
|
+
this.#setActiveItem(
|
|
378
|
+
this.#activeIndex === null ? this.#flatOptions.length - 1 : this.#activeIndex - 1);
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
|
|
382
|
+
case 'Enter':
|
|
383
|
+
case ' ':
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
if (!this.#open) {
|
|
386
|
+
this.#handleOpenSelect();
|
|
387
|
+
} else if (this.#activeIndex !== null) {
|
|
388
|
+
this.#handleSelection(this.#activeIndex);
|
|
389
|
+
this.#handleCloseSelect();
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
392
|
+
|
|
393
|
+
case 'Escape':
|
|
394
|
+
this.#handleCloseSelect();
|
|
395
|
+
return;
|
|
396
|
+
|
|
397
|
+
case 'Tab':
|
|
398
|
+
this.#handleCloseSelect();
|
|
399
|
+
return;
|
|
400
|
+
|
|
401
|
+
default:
|
|
402
|
+
this.#handleTypeAhead(e);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#handleTypeAhead(e) {
|
|
407
|
+
if (e.key.length !== 1) return;
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
clearTimeout(this.#timer);
|
|
410
|
+
this.#buffer += e.key;
|
|
411
|
+
|
|
412
|
+
const matchIndex = JhFilter.jumpAhead(
|
|
413
|
+
this.#flatOptions,
|
|
414
|
+
this.#buffer,
|
|
415
|
+
this.#activeIndex,
|
|
416
|
+
'label',
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
if (matchIndex !== -1) {
|
|
420
|
+
this.#setActiveItem(matchIndex);
|
|
421
|
+
this.#handleSelection(matchIndex);
|
|
422
|
+
}
|
|
423
|
+
this.#timer = setTimeout(() => (this.#buffer = ''), 500);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#handleMenuClick(e) {
|
|
427
|
+
const item = e.target.closest('jh-list-item');
|
|
428
|
+
if (!item || item.disabled) return;
|
|
429
|
+
|
|
430
|
+
const index = this.#getIndexFromId(item.id);
|
|
431
|
+
if (index === null) return;
|
|
432
|
+
|
|
433
|
+
this.#handleSelection(index);
|
|
434
|
+
this.#handleCloseSelect();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#handleTriggerClick() {
|
|
438
|
+
if (this.#open) {
|
|
439
|
+
this.#handleCloseSelect();
|
|
440
|
+
} else {
|
|
441
|
+
this.#handleOpenSelect();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#handleSelection(index) {
|
|
446
|
+
const option = this.#flatOptions[index];
|
|
447
|
+
if (!option || option.disabled) return;
|
|
448
|
+
|
|
449
|
+
if (this.value !== String(option.value)) {
|
|
450
|
+
this.value = String(option.value);
|
|
451
|
+
this.#displayValue = option.label != null ? option.label : String(option.value);
|
|
452
|
+
this.#activeIndex = index;
|
|
453
|
+
this.requestUpdate();
|
|
454
|
+
this.#scrollToActiveItem();
|
|
455
|
+
|
|
456
|
+
//dispatch a jh-change event when the selected value changes.
|
|
457
|
+
const options = {
|
|
458
|
+
bubbles: true,
|
|
459
|
+
composed: true,
|
|
460
|
+
cancelable: true,
|
|
461
|
+
};
|
|
462
|
+
this.dispatchEvent(new CustomEvent('jh-change', options));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
//method to flip the menu if it is not fully visible on the viewport.
|
|
467
|
+
//the menu will only flip if it is fully visible in the opposite direction.
|
|
468
|
+
//if there is not enough space in either direction, the menu will stay in the original position.
|
|
469
|
+
#flipMenu() {
|
|
470
|
+
const currentPosition = this.menuPosition;
|
|
471
|
+
|
|
472
|
+
//check if current position is a valid position otherwise make it fail.
|
|
473
|
+
if (!['top', 'bottom'].includes(currentPosition)) return;
|
|
474
|
+
|
|
475
|
+
if (this.flipDisabled) {
|
|
476
|
+
this.#setMenuAnchor(currentPosition);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
//get an array of the available positions
|
|
481
|
+
const availablePositions = this.#getValidPositions();
|
|
482
|
+
|
|
483
|
+
//if only 1 position is available, set the menu anchor to that position, otherwise keep the current position.
|
|
484
|
+
const newPosition = availablePositions.length === 1 ? availablePositions[0] : currentPosition;
|
|
485
|
+
this.#setMenuAnchor(newPosition);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#setMenuAnchor(position) {
|
|
489
|
+
const hostRect = this.getBoundingClientRect();
|
|
490
|
+
const inputRect = this.#inputWrapper.getBoundingClientRect();
|
|
491
|
+
|
|
492
|
+
// Convert viewport coordinates to host-relative coordinates
|
|
493
|
+
const inputTopRelative = inputRect.top - hostRect.top;
|
|
494
|
+
const inputBottomRelative = inputRect.bottom - hostRect.top;
|
|
495
|
+
|
|
496
|
+
this.#menuContainer.style.top = '';
|
|
497
|
+
this.#menuContainer.style.bottom = '';
|
|
498
|
+
|
|
499
|
+
if (position === 'bottom') {
|
|
500
|
+
// Menu opens right below the native input
|
|
501
|
+
this.#menuContainer.style.top = `${inputBottomRelative}px`;
|
|
502
|
+
} else if (position === 'top') {
|
|
503
|
+
// Menu opens right above the native input
|
|
504
|
+
// bottom is relative to the bottom of :host
|
|
505
|
+
const inputTopFromHostBottom = hostRect.height - inputTopRelative;
|
|
506
|
+
this.#menuContainer.style.bottom = `${inputTopFromHostBottom}px`;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
//method to check if the menu is fully visible on the screen
|
|
511
|
+
#getValidPositions() {
|
|
512
|
+
const { menuHeight } = this.#getDimensions();
|
|
513
|
+
|
|
514
|
+
const { elemTop, elemBottom } = this.#getCoordinates();
|
|
515
|
+
|
|
516
|
+
const elemFromBottom = window.innerHeight - elemBottom;
|
|
517
|
+
|
|
518
|
+
//Returns false if menu with position top is out of the screen on the top
|
|
519
|
+
const topOutTop = elemTop - menuHeight > 0;
|
|
520
|
+
|
|
521
|
+
//Returns false if menu with position bottom is out of the screen on the bottom
|
|
522
|
+
const bottomOutBottom = elemFromBottom - menuHeight > 0;
|
|
523
|
+
|
|
524
|
+
//returns true if the condition is met.
|
|
525
|
+
const allPositions = {
|
|
526
|
+
top: topOutTop,
|
|
527
|
+
bottom: bottomOutBottom,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
//add valid positions to an array and return it.
|
|
531
|
+
const validPositions = Object.entries(allPositions).reduce(
|
|
532
|
+
(positions, [key, value]) => (value ? [...positions, key] : positions),
|
|
533
|
+
[],
|
|
534
|
+
);
|
|
535
|
+
return validPositions;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
//get the height of the menu
|
|
539
|
+
#getDimensions() {
|
|
540
|
+
return {
|
|
541
|
+
menuHeight: this.shadowRoot.querySelector('jh-menu').getBoundingClientRect().height
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
//get the coordinates of the top and bottom edge of the input without label/error text.
|
|
546
|
+
#getCoordinates() {
|
|
547
|
+
const inputRect = this.#inputWrapper.getBoundingClientRect();
|
|
548
|
+
return {
|
|
549
|
+
elemTop: inputRect.top,
|
|
550
|
+
elemBottom: inputRect.bottom,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
renderLeftSlot() {
|
|
555
|
+
return html` <slot name="jh-input-left" @slotchange=${this._handleSlotChange}>
|
|
556
|
+
<slot name="jh-select-trigger-left"></slot>
|
|
557
|
+
</slot>`;
|
|
558
|
+
}
|
|
559
|
+
renderRightSlot() {
|
|
560
|
+
return html` <slot name="jh-input-right" @slotchange=${this._handleSlotChange}>
|
|
561
|
+
${this.#open
|
|
562
|
+
? html`<slot name="jh-select-trigger-open"><jh-icon-chevron-up-small></jh-icon-chevron-up-small></slot>`
|
|
563
|
+
: html`<slot name="jh-select-trigger-closed"><jh-icon-chevron-down-small></jh-icon-chevron-down-small></slot>`}
|
|
564
|
+
</slot>`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
renderInput() {
|
|
568
|
+
const describedby =
|
|
569
|
+
this.helperText || (this.errorText && this.invalid) ? this._getDescribedby() : undefined;
|
|
570
|
+
const leftSlot = this.readonly ? null : this.renderLeftSlot();
|
|
571
|
+
const rightSlot = this.readonly ? null : this.renderRightSlot();
|
|
572
|
+
const clearButton = this.renderClearButton();
|
|
573
|
+
|
|
574
|
+
return html`
|
|
575
|
+
<div class="input-container">
|
|
576
|
+
<div class="input-wrapper" @click=${this.#handleTriggerClick}>
|
|
577
|
+
${leftSlot}
|
|
578
|
+
<input
|
|
579
|
+
role="combobox"
|
|
580
|
+
type="text"
|
|
581
|
+
readonly
|
|
582
|
+
autocomplete="off"
|
|
583
|
+
aria-haspopup="listbox"
|
|
584
|
+
aria-controls="jh-select-listbox-${this.uniqueId}"
|
|
585
|
+
id="jh-input-${this.uniqueId}"
|
|
586
|
+
aria-expanded=${this.#open ? 'true' : 'false'}
|
|
587
|
+
aria-activedescendant=${ifDefined(
|
|
588
|
+
this.#activeIndex !== null
|
|
589
|
+
? `jh-select-option-${this.uniqueId}-${this.#activeIndex}`
|
|
590
|
+
: undefined,
|
|
591
|
+
)}
|
|
592
|
+
aria-describedby=${ifDefined(describedby)}
|
|
593
|
+
aria-invalid=${ifDefined(this.invalid ? 'true' : undefined)}
|
|
594
|
+
aria-label=${ifDefined(this.accessibleLabel)}
|
|
595
|
+
?disabled=${this.disabled}
|
|
596
|
+
?required=${this.required}
|
|
597
|
+
.value=${this.#displayValue ?? ''} />
|
|
598
|
+
${clearButton} ${rightSlot}
|
|
599
|
+
</div>
|
|
600
|
+
</div>
|
|
601
|
+
`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
renderData(options) {
|
|
605
|
+
if (!options) return null;
|
|
606
|
+
let flatIndex = 0;
|
|
607
|
+
|
|
608
|
+
return options.map((option) => {
|
|
609
|
+
if (option.groupValues) {
|
|
610
|
+
const groupItems = option.groupValues.map((groupOption) => {
|
|
611
|
+
const idx = flatIndex++;
|
|
612
|
+
return html`<jh-list-item
|
|
613
|
+
role="option"
|
|
614
|
+
tabindex="-1"
|
|
615
|
+
?disabled=${groupOption.disabled}
|
|
616
|
+
?selected=${String(this.value) === String(groupOption.value)}
|
|
617
|
+
aria-selected=${String(this.value) === String(groupOption.value)}
|
|
618
|
+
id="jh-select-option-${this.uniqueId}-${idx}"
|
|
619
|
+
class="${this.#activeIndex === idx ? 'is-active' : ''}"
|
|
620
|
+
primary-text=${groupOption.label != null
|
|
621
|
+
? groupOption.label
|
|
622
|
+
: String(groupOption.value)}></jh-list-item>`;
|
|
623
|
+
});
|
|
624
|
+
return html`<jh-list-group label=${option.groupLabel}>${groupItems}</jh-list-group>`;
|
|
625
|
+
}
|
|
626
|
+
const idx = flatIndex++;
|
|
627
|
+
return html`<jh-list-item
|
|
628
|
+
role="option"
|
|
629
|
+
tabindex="-1"
|
|
630
|
+
?disabled=${option.disabled}
|
|
631
|
+
?selected=${String(this.value) === String(option.value)}
|
|
632
|
+
aria-selected=${String(this.value) === String(option.value)}
|
|
633
|
+
id="jh-select-option-${this.uniqueId}-${idx}"
|
|
634
|
+
class="${this.#activeIndex === idx ? 'is-active' : ''}"
|
|
635
|
+
primary-text=${option.label != null ? option.label : String(option.value)}></jh-list-item>`;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
render() {
|
|
640
|
+
const label = this.renderLabel();
|
|
641
|
+
const input = this.renderInput();
|
|
642
|
+
const footer = this.renderFooter();
|
|
643
|
+
|
|
644
|
+
return html`
|
|
645
|
+
${label} ${input} ${footer}
|
|
646
|
+
${this.options && this.options.length
|
|
647
|
+
? html` <div class="menu-container ${this.#open ? 'show' : ''}">
|
|
648
|
+
<jh-menu
|
|
649
|
+
role="listbox"
|
|
650
|
+
id="jh-select-listbox-${this.uniqueId}"
|
|
651
|
+
@click=${this.#handleMenuClick}>
|
|
652
|
+
${this.renderData(this.options)}
|
|
653
|
+
</jh-menu>
|
|
654
|
+
</div>`
|
|
655
|
+
: null}
|
|
656
|
+
`;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
customElements.define('jh-select', JhSelect);
|
|
@@ -74,8 +74,6 @@ export class JhToast extends LitElement {
|
|
|
74
74
|
|
|
75
75
|
static get properties() {
|
|
76
76
|
return {
|
|
77
|
-
/** Sets the background color of the toast to convey message connotation. */
|
|
78
|
-
appearance: { type: String, reflect: true },
|
|
79
77
|
/** Adds an aria-label to the dismiss button to assist screen readers. */
|
|
80
78
|
dismissButtonAccessibleLabel: {
|
|
81
79
|
type: String,
|
|
@@ -94,8 +92,6 @@ export class JhToast extends LitElement {
|
|
|
94
92
|
super();
|
|
95
93
|
/** @type {number} */
|
|
96
94
|
this.timeout = 5000;
|
|
97
|
-
/** @type {'positive'|'neutral'|'negative'} */
|
|
98
|
-
this.appearance = 'neutral';
|
|
99
95
|
/** @type {string} */
|
|
100
96
|
this.dismissButtonAccessibleLabel = null;
|
|
101
97
|
/** @type {boolean} */
|
|
@@ -151,7 +147,7 @@ export class JhToast extends LitElement {
|
|
|
151
147
|
render() {
|
|
152
148
|
return html`
|
|
153
149
|
<jh-notification
|
|
154
|
-
appearance
|
|
150
|
+
appearance="neutral"
|
|
155
151
|
dismiss-button-accessible-label=${this.dismissButtonAccessibleLabel}
|
|
156
152
|
?hide-dismiss-button=${this.hideDismissButton}
|
|
157
153
|
?stacked=${this.stacked}>
|