asljs-ui-select 0.0.0-stage → 0.1.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.
package/LICENSE.md ADDED
@@ -0,0 +1,51 @@
1
+ # Permissive No-Derivatives License 1.0
2
+
3
+ Copyright (c) 2026 Alexandrite Software Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software (the "Software") to:
7
+
8
+ 1. Use the Software for any purpose, including commercial purposes.
9
+ 2. Copy and redistribute the Software, including as part of another application,
10
+ library, or package.
11
+ 3. Modify the Software for private or internal use.
12
+
13
+ Subject to the following conditions:
14
+
15
+ ## 1. No Distribution of Modified Versions
16
+
17
+ Modified versions of the Software, or substantial portions thereof, must not be
18
+ distributed without prior written permission from the copyright holder.
19
+
20
+ Routine build transformations, including minification, transpilation, and
21
+ bundling, are permitted for distribution, provided they do not intentionally
22
+ alter the Software's functionality.
23
+
24
+ ## 2. Licence and Copyright Preservation
25
+
26
+ All distributed copies of the Software must retain this copyright notice and
27
+ licence.
28
+
29
+ The Software itself, including copies incorporated into other applications,
30
+ libraries, or packages, remains subject to this licence.
31
+
32
+ The Software must not be sublicensed or redistributed under a different licence.
33
+
34
+ ## 3. Use in Other Software
35
+
36
+ Applications and libraries that merely use, import, link to, or bundle the
37
+ Software are not considered modified versions of the Software and may be
38
+ distributed under any licence.
39
+
40
+ Such use, incorporation, or bundling does not grant permission to relicense the
41
+ Software or distribute modified versions of it.
42
+
43
+ ### 4. No Warranty
44
+
45
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
46
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
47
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
48
+
49
+ IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR
50
+ OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS
51
+ USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # select
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Part of [Alexandrite Software Library][#1] - a set of high-quality, performant
4
+ > JavaScript libraries and tools for everyday use.
5
+
6
+ ## Overview
7
+
8
+ `asljs-ui-select` provides `asljs-select`, a labelled drop-down with validation.
9
+ `items` lists the options, `value` sets or resets the selection, and the user's
10
+ choice is reported through `input` and `change` events.
11
+
12
+ ## Scope
13
+
14
+ - Explicit `{ value, label, disabled? }` items and an optional placeholder.
15
+ - Validation through a `validator` function returning an error message.
16
+ - Layout and control markup from templates: a local slot template, the `select`
17
+ theme section, or the Bootstrap part, `createBootstrapSelectTheme()`.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install asljs-ui-select
23
+ ```
24
+
25
+ NPM package: [asljs-ui-select][NPM]
26
+
27
+ ## Usage
28
+
29
+ Import the package once to register the element:
30
+
31
+ ```ts
32
+ import 'asljs-ui-select';
33
+
34
+ const select =
35
+ document.createElement('asljs-select') as HTMLElement & {
36
+ label: string | null;
37
+ items: Array<{ value: string; label: string }>;
38
+ value: string | null;
39
+ };
40
+
41
+ select.label = 'Size';
42
+ select.items =
43
+ [ { value: 's', label: 'Small' },
44
+ { value: 'm', label: 'Medium' } ];
45
+ select.value = 'm';
46
+
47
+ select.addEventListener(
48
+ 'change',
49
+ event => console.log((event as CustomEvent<{ value: string }>).detail.value));
50
+
51
+ document.body.appendChild(select);
52
+ ```
53
+
54
+ ## Further reading
55
+
56
+ - [Select][DOC] - properties, events and templates.
57
+ - [Theming][THM] - templates, themes and how they combine.
58
+
59
+ Questions and bugs: [asljs/issues][ISS].
60
+
61
+ ## Related packages
62
+
63
+ - [ui][UI] - the shared theme and the other UI packages.
64
+ - [text-input][TXT] - the text field with the same set/reset contract.
65
+ - [select-lookup][SLK] - a drop-down that searches as the user types.
66
+
67
+ ## License
68
+
69
+ MIT License. See [LICENSE][LIC] for details.
70
+
71
+ [#1]: https://github.com/AlexandriteSoftware/asljs
72
+ [DOC]: docs/Select.md
73
+ [ISS]: https://github.com/AlexandriteSoftware/asljs/issues
74
+ [LIC]: LICENSE.md
75
+ [NPM]: https://www.npmjs.com/package/asljs-ui-select
76
+ [SLK]: ../select-lookup/README.md
77
+ [THM]: ../core/docs/Theming.md
78
+ [TXT]: ../text-input/README.md
79
+ [UI]: ../core/README.md
@@ -0,0 +1,6 @@
1
+ import { type BindDataModelOptions } from "asljs-data-binding";
2
+ import type { SelectTemplateModel as TemplateModel } from "./select.js";
3
+ /** The markup inside the template's <template> element. */
4
+ export declare const html = "\n <div class=\"mb-3\"\n data-bind-class-asljs-select-empty=\"isEmpty\"\n data-bind-class-asljs-select-invalid=\"hasError\">\n <label class=\"form-label\"\n data-bind-text=\"label\"\n data-bind-prop-hidden=\"hideLabel\"\n data-bind-for=\"inputId\"></label>\n <div data-role=\"control-host\"></div>\n <div class=\"invalid-feedback\"\n data-bind-text=\"errorMessage\"\n data-bind-prop-hidden=\"hideError\"\n data-bind-prop-id=\"errorId\"></div>\n <div class=\"form-text\"\n data-bind-text=\"description\"\n data-bind-prop-hidden=\"hideDescription\"\n data-bind-prop-id=\"descriptionId\"></div>\n </div>\n";
5
+ export declare function createTemplate(doc?: Document): HTMLTemplateElement;
6
+ export declare function bindTemplate(root: ParentNode, model: TemplateModel, options?: BindDataModelOptions): () => boolean;
@@ -0,0 +1,28 @@
1
+ // Generated by data-bind-compile from bootstrap-select.tpl.html. Do not edit.
2
+ import { bindDataModel } from "asljs-data-binding";
3
+ /** The markup inside the template's <template> element. */
4
+ export const html = "\n <div class=\"mb-3\"\n data-bind-class-asljs-select-empty=\"isEmpty\"\n data-bind-class-asljs-select-invalid=\"hasError\">\n <label class=\"form-label\"\n data-bind-text=\"label\"\n data-bind-prop-hidden=\"hideLabel\"\n data-bind-for=\"inputId\"></label>\n <div data-role=\"control-host\"></div>\n <div class=\"invalid-feedback\"\n data-bind-text=\"errorMessage\"\n data-bind-prop-hidden=\"hideError\"\n data-bind-prop-id=\"errorId\"></div>\n <div class=\"form-text\"\n data-bind-text=\"description\"\n data-bind-prop-hidden=\"hideDescription\"\n data-bind-prop-id=\"descriptionId\"></div>\n </div>\n";
5
+ export function createTemplate(doc = document) {
6
+ const template = doc.createElement('template');
7
+ template.innerHTML = html;
8
+ return template;
9
+ }
10
+ export function bindTemplate(root, model, options) {
11
+ return bindDataModel(root, model, options);
12
+ }
13
+ // Never called: each statement checks one binding against the model and
14
+ // element types, and names the template line it comes from.
15
+ function check(model) {
16
+ void model.isEmpty; // bootstrap-select.tpl.html:3:8 data-bind-class-asljs-select-empty
17
+ void model.hasError; // bootstrap-select.tpl.html:4:8 data-bind-class-asljs-select-invalid
18
+ void model.label; // bootstrap-select.tpl.html:6:12 data-bind-text
19
+ void model.hideLabel; // bootstrap-select.tpl.html:7:12 data-bind-prop-hidden
20
+ void model.inputId; // bootstrap-select.tpl.html:8:12 data-bind-for
21
+ void model.errorMessage; // bootstrap-select.tpl.html:11:10 data-bind-text
22
+ void model.hideError; // bootstrap-select.tpl.html:12:10 data-bind-prop-hidden
23
+ void model.errorId; // bootstrap-select.tpl.html:13:10 data-bind-prop-id
24
+ void model.description; // bootstrap-select.tpl.html:15:10 data-bind-text
25
+ void model.hideDescription; // bootstrap-select.tpl.html:16:10 data-bind-prop-hidden
26
+ void model.descriptionId; // bootstrap-select.tpl.html:17:10 data-bind-prop-id
27
+ }
28
+ void check;
@@ -0,0 +1,2 @@
1
+ export { Select, SelectModelDefinition, type SelectChangeDetail, type SelectItem, type SelectStatus, type SelectValidator } from './select.js';
2
+ export { createBootstrapSelectTheme, type SelectThemeDefinition } from './theme.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Select, SelectModelDefinition } from './select.js';
2
+ export { createBootstrapSelectTheme } from './theme.js';
@@ -0,0 +1,59 @@
1
+ import { type Converted } from 'asljs-observable';
2
+ import { ComponentModelDefinition, ComponentsTheme } from 'asljs-ui';
3
+ import { LitElement } from 'lit';
4
+ export interface SelectItem {
5
+ value: string;
6
+ label: string;
7
+ disabled?: boolean;
8
+ }
9
+ export type SelectValidator = (value: string) => string | null;
10
+ export interface SelectChangeDetail {
11
+ value: string;
12
+ isEmpty: boolean;
13
+ isValid: boolean;
14
+ errorMessage: string | null;
15
+ dirty: boolean;
16
+ }
17
+ export type SelectStatus = Converted<{
18
+ draftValue: string;
19
+ isEmpty: boolean;
20
+ isValid: boolean;
21
+ errorMessage: string | null;
22
+ dirty: boolean;
23
+ }>;
24
+ export type SelectTemplateModel = Converted<{
25
+ label: string;
26
+ description: string;
27
+ errorMessage: string;
28
+ hideLabel: boolean;
29
+ hideDescription: boolean;
30
+ hideError: boolean;
31
+ hasError: boolean;
32
+ isEmpty: boolean;
33
+ inputId: string;
34
+ descriptionId: string;
35
+ errorId: string;
36
+ }>;
37
+ export declare const SelectModelDefinition: ComponentModelDefinition;
38
+ export declare class Select extends LitElement {
39
+ #private;
40
+ readonly status: SelectStatus;
41
+ accessor label: string | null;
42
+ accessor description: string | null;
43
+ accessor validator: SelectValidator | null;
44
+ accessor theme: ComponentsTheme | null;
45
+ accessor value: string | null;
46
+ accessor placeholder: string | null;
47
+ accessor items: SelectItem[];
48
+ accessor disabled: boolean;
49
+ accessor controlClassName: string;
50
+ get draftValue(): string;
51
+ get isEmpty(): boolean;
52
+ get isValid(): boolean;
53
+ get errorMessage(): string | null;
54
+ createRenderRoot(): this;
55
+ connectedCallback(): void;
56
+ disconnectedCallback(): void;
57
+ render(): ReturnType<LitElement['render']>;
58
+ updated(changedProperties: Map<PropertyKey, unknown>): void;
59
+ }
package/dist/select.js ADDED
@@ -0,0 +1,508 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { bindDataModel } from 'asljs-data-binding';
8
+ import { observable } from 'asljs-observable';
9
+ import { defineComponentModel, findThemeProvider, getDefaultTheme, resolveThemeTemplate, THEME_CHANGED_EVENT_NAME } from 'asljs-ui';
10
+ import { html, LitElement } from 'lit';
11
+ import { customElement, property } from 'lit/decorators.js';
12
+ import { createTemplate } from './select.tpl.js';
13
+ export const SelectModelDefinition = { name: 'SelectModelDefinition',
14
+ title: 'Select',
15
+ properties: [{ name: 'label',
16
+ title: 'Label',
17
+ type: 'string' },
18
+ { name: 'description',
19
+ title: 'Description',
20
+ type: 'string' },
21
+ { name: 'validator',
22
+ title: 'Validator',
23
+ type: 'function',
24
+ description: 'Validation function that returns an error message or null.' },
25
+ { name: 'theme',
26
+ title: 'Theme',
27
+ type: 'object',
28
+ description: 'Per-instance components theme override.' },
29
+ { name: 'value',
30
+ title: 'Value',
31
+ type: 'string' },
32
+ { name: 'placeholder',
33
+ title: 'Placeholder',
34
+ type: 'string' },
35
+ { name: 'items',
36
+ title: 'Items',
37
+ type: 'array' },
38
+ { name: 'disabled',
39
+ title: 'Disabled',
40
+ type: 'boolean' },
41
+ { name: 'controlClassName',
42
+ title: 'Control class name',
43
+ type: 'string' },
44
+ { name: 'status',
45
+ title: 'Status',
46
+ type: 'object',
47
+ description: 'Observable draft status object.',
48
+ editable: false },
49
+ { name: 'draftValue',
50
+ title: 'Draft value',
51
+ type: 'string',
52
+ description: 'Current in-progress selection.',
53
+ editable: false },
54
+ { name: 'isEmpty',
55
+ title: 'Is empty',
56
+ type: 'boolean',
57
+ editable: false },
58
+ { name: 'isValid',
59
+ title: 'Is valid',
60
+ type: 'boolean',
61
+ editable: false },
62
+ { name: 'errorMessage',
63
+ title: 'Error message',
64
+ type: 'string',
65
+ description: 'Current validation message or null.',
66
+ editable: false }] };
67
+ defineComponentModel('asljs-select', SelectModelDefinition);
68
+ let Select = class Select extends LitElement {
69
+ constructor() {
70
+ super(...arguments);
71
+ this.#templateElement = null;
72
+ this.#selectTemplateElement = null;
73
+ this.#templateDispose = null;
74
+ this.#controlTemplateDispose = null;
75
+ this.#themeProvider = null;
76
+ this.#control = null;
77
+ this.#controlBaseClassName = '';
78
+ this.#controlInvalidClassName = null;
79
+ this.#controlChangeListener = null;
80
+ this.#idBase = `asljs-select-${nextSelectId++}`;
81
+ this.#model = observable({ label: '',
82
+ description: '',
83
+ errorMessage: '',
84
+ hideLabel: true,
85
+ hideDescription: true,
86
+ hideError: true,
87
+ hasError: false,
88
+ isEmpty: true,
89
+ inputId: `${this.#idBase}-control`,
90
+ descriptionId: `${this.#idBase}-description`,
91
+ errorId: `${this.#idBase}-error` });
92
+ this.status = observable({ draftValue: '',
93
+ isEmpty: true,
94
+ isValid: true,
95
+ errorMessage: null,
96
+ dirty: false });
97
+ this.#label_accessor_storage = null;
98
+ this.#description_accessor_storage = null;
99
+ this.#validator_accessor_storage = null;
100
+ this.#theme_accessor_storage = null;
101
+ this.#value_accessor_storage = '';
102
+ this.#placeholder_accessor_storage = null;
103
+ this.#items_accessor_storage = [];
104
+ this.#disabled_accessor_storage = false;
105
+ this.#controlClassName_accessor_storage = '';
106
+ this.#handleThemeChanged = () => {
107
+ this.#renderTemplate();
108
+ };
109
+ }
110
+ #templateElement;
111
+ #selectTemplateElement;
112
+ #templateDispose;
113
+ #controlTemplateDispose;
114
+ #themeProvider;
115
+ #control;
116
+ #controlBaseClassName;
117
+ #controlInvalidClassName;
118
+ #controlChangeListener;
119
+ #idBase;
120
+ #model;
121
+ #label_accessor_storage;
122
+ get label() { return this.#label_accessor_storage; }
123
+ set label(value) { this.#label_accessor_storage = value; }
124
+ #description_accessor_storage;
125
+ get description() { return this.#description_accessor_storage; }
126
+ set description(value) { this.#description_accessor_storage = value; }
127
+ #validator_accessor_storage;
128
+ get validator() { return this.#validator_accessor_storage; }
129
+ set validator(value) { this.#validator_accessor_storage = value; }
130
+ #theme_accessor_storage;
131
+ get theme() { return this.#theme_accessor_storage; }
132
+ set theme(value) { this.#theme_accessor_storage = value; }
133
+ #value_accessor_storage;
134
+ get value() { return this.#value_accessor_storage; }
135
+ set value(value) { this.#value_accessor_storage = value; }
136
+ #placeholder_accessor_storage;
137
+ get placeholder() { return this.#placeholder_accessor_storage; }
138
+ set placeholder(value) { this.#placeholder_accessor_storage = value; }
139
+ #items_accessor_storage;
140
+ get items() { return this.#items_accessor_storage; }
141
+ set items(value) { this.#items_accessor_storage = value; }
142
+ #disabled_accessor_storage;
143
+ get disabled() { return this.#disabled_accessor_storage; }
144
+ set disabled(value) { this.#disabled_accessor_storage = value; }
145
+ #controlClassName_accessor_storage;
146
+ get controlClassName() { return this.#controlClassName_accessor_storage; }
147
+ set controlClassName(value) { this.#controlClassName_accessor_storage = value; }
148
+ get draftValue() {
149
+ return this.status.draftValue;
150
+ }
151
+ get isEmpty() {
152
+ return this.status.isEmpty;
153
+ }
154
+ get isValid() {
155
+ return this.status.isValid;
156
+ }
157
+ get errorMessage() {
158
+ return this.status.errorMessage;
159
+ }
160
+ createRenderRoot() {
161
+ return this;
162
+ }
163
+ connectedCallback() {
164
+ super.connectedCallback();
165
+ this.#captureTemplates();
166
+ this.#syncThemeProvider();
167
+ this.#applyExternalValue();
168
+ }
169
+ disconnectedCallback() {
170
+ this.#disposeControlBindings();
171
+ this.#templateDispose?.();
172
+ this.#templateDispose = null;
173
+ this.#controlTemplateDispose?.();
174
+ this.#controlTemplateDispose = null;
175
+ this.#disposeThemeProvider();
176
+ super.disconnectedCallback();
177
+ }
178
+ render() {
179
+ return html `
180
+ <div data-role="template-host"></div>
181
+ `;
182
+ }
183
+ updated(changedProperties) {
184
+ if (changedProperties.has('theme')) {
185
+ this.#syncThemeProvider();
186
+ }
187
+ if (changedProperties.has('value')) {
188
+ this.#applyExternalValue();
189
+ }
190
+ if (changedProperties.has('label')
191
+ || changedProperties.has('description')
192
+ || changedProperties.has('theme')) {
193
+ this.#renderTemplate();
194
+ return;
195
+ }
196
+ if (changedProperties.has('placeholder')
197
+ || changedProperties.has('disabled')
198
+ || changedProperties.has('items')
199
+ || changedProperties.has('controlClassName')) {
200
+ this.#syncControlState();
201
+ }
202
+ this.#syncModelState();
203
+ }
204
+ #captureTemplates() {
205
+ this.#templateElement =
206
+ cloneNamedTemplate(this, 'template');
207
+ this.#selectTemplateElement =
208
+ cloneNamedTemplate(this, 'select');
209
+ }
210
+ #syncThemeProvider() {
211
+ this.#disposeThemeProvider();
212
+ this.#themeProvider =
213
+ findThemeProvider(this);
214
+ if (this.#themeProvider === null) {
215
+ return;
216
+ }
217
+ this.#themeProvider.addEventListener(THEME_CHANGED_EVENT_NAME, this.#handleThemeChanged);
218
+ }
219
+ #disposeThemeProvider() {
220
+ this.#themeProvider?.removeEventListener(THEME_CHANGED_EVENT_NAME, this.#handleThemeChanged);
221
+ this.#themeProvider = null;
222
+ }
223
+ #handleThemeChanged;
224
+ #applyExternalValue() {
225
+ this.status.draftValue =
226
+ normalizeText(this.value);
227
+ this.#syncModelState();
228
+ this.#syncControlState();
229
+ }
230
+ #syncModelState() {
231
+ const label = normalizeOptionalText(this.label);
232
+ const description = normalizeOptionalText(this.description);
233
+ let errorMessage = null;
234
+ const validator = this.validator;
235
+ if (validator !== null) {
236
+ errorMessage =
237
+ validator(this.status.draftValue);
238
+ }
239
+ const isEmpty = this.status.draftValue === '';
240
+ const dirty = this.status.draftValue !== normalizeText(this.value);
241
+ this.status.isEmpty = isEmpty;
242
+ this.status.errorMessage = errorMessage;
243
+ this.status.isValid = errorMessage === null;
244
+ this.status.dirty = dirty;
245
+ this.#model.label = label ?? '';
246
+ this.#model.description = description ?? '';
247
+ this.#model.errorMessage = errorMessage ?? '';
248
+ this.#model.hideLabel = label === null;
249
+ this.#model.hideDescription = description === null;
250
+ this.#model.hideError = errorMessage === null;
251
+ this.#model.hasError = errorMessage !== null;
252
+ this.#model.isEmpty = isEmpty;
253
+ this.#syncControlState();
254
+ }
255
+ #renderTemplate() {
256
+ const templateHost = this.querySelector('[data-role="template-host"]');
257
+ if (templateHost === null) {
258
+ return;
259
+ }
260
+ this.#templateDispose?.();
261
+ this.#templateDispose = null;
262
+ this.#controlTemplateDispose?.();
263
+ this.#controlTemplateDispose = null;
264
+ this.#disposeControlBindings();
265
+ const template = this.#resolveTemplate('template');
266
+ if (template === null) {
267
+ templateHost.replaceChildren();
268
+ return;
269
+ }
270
+ const fragment = template.content.cloneNode(true);
271
+ this.#templateDispose =
272
+ bindDataModel(fragment, this.#model);
273
+ templateHost.replaceChildren(fragment);
274
+ this.#mountControl();
275
+ this.#syncModelState();
276
+ }
277
+ #resolveTemplate(slotName) {
278
+ return this.#getLocalTemplate(slotName)
279
+ ?? this.#getThemeTemplate(slotName)
280
+ ?? createDefaultSelectTemplate(slotName);
281
+ }
282
+ #getLocalTemplate(slotName) {
283
+ return slotName === 'template'
284
+ ? this.#templateElement
285
+ : this.#selectTemplateElement;
286
+ }
287
+ #getThemeTemplate(slotName) {
288
+ const theme = this.theme
289
+ ?? this.#themeProvider?.theme
290
+ ?? getDefaultTheme();
291
+ return resolveThemeTemplate(slotName === 'template'
292
+ ? theme.select?.template
293
+ : theme.select?.select, this);
294
+ }
295
+ #mountControl() {
296
+ const controlHost = this.querySelector('[data-role="control-host"]');
297
+ if (controlHost === null) {
298
+ return;
299
+ }
300
+ const mountedControl = this.#createMountedControl(controlHost);
301
+ this.#controlTemplateDispose =
302
+ bindDataModel(mountedControl.fragment, this.#model);
303
+ controlHost.replaceChildren(mountedControl.fragment);
304
+ this.#control =
305
+ mountedControl.control;
306
+ this.#controlBaseClassName =
307
+ mountedControl.className;
308
+ this.#controlInvalidClassName =
309
+ mountedControl.invalidClassName;
310
+ const changeListener = () => {
311
+ if (this.#control === null) {
312
+ return;
313
+ }
314
+ this.status.draftValue = this.#control.value;
315
+ this.#syncModelState();
316
+ this.#dispatchValueEvent('input');
317
+ this.#dispatchValueEvent('change');
318
+ };
319
+ this.#control.addEventListener('change', changeListener);
320
+ this.#controlChangeListener =
321
+ () => {
322
+ this.#control?.removeEventListener('change', changeListener);
323
+ };
324
+ this.#syncControlState();
325
+ }
326
+ #syncControlState() {
327
+ const control = this.#control;
328
+ if (control === null) {
329
+ return;
330
+ }
331
+ control.replaceChildren();
332
+ const placeholder = normalizeOptionalText(this.placeholder);
333
+ if (placeholder !== null) {
334
+ const option = document.createElement('option');
335
+ option.value = '';
336
+ option.textContent = placeholder;
337
+ control.appendChild(option);
338
+ }
339
+ for (const item of normalizeItems(this.items)) {
340
+ const option = document.createElement('option');
341
+ option.value = item.value;
342
+ option.textContent = item.label;
343
+ option.disabled = item.disabled ?? false;
344
+ control.appendChild(option);
345
+ }
346
+ const candidateValues = Array.from(control.options).map(option => option.value);
347
+ const nextValue = candidateValues.includes(this.status.draftValue)
348
+ ? this.status.draftValue
349
+ : placeholder !== null
350
+ ? ''
351
+ : candidateValues[0] ?? '';
352
+ if (this.status.draftValue
353
+ !== nextValue) {
354
+ this.status.draftValue = nextValue;
355
+ this.#syncModelState();
356
+ }
357
+ control.value = nextValue;
358
+ control.id = this.#model.inputId;
359
+ control.disabled = this.disabled;
360
+ control.className =
361
+ joinClassNames(this.#controlBaseClassName, this.controlClassName);
362
+ if (this.#controlInvalidClassName
363
+ !== null
364
+ && !this.status.isValid) {
365
+ control.classList.add(this.#controlInvalidClassName);
366
+ }
367
+ control.toggleAttribute('aria-invalid', !this.status.isValid);
368
+ control.setAttribute('aria-describedby', resolveAriaDescribedBy(this.#model));
369
+ }
370
+ #createMountedControl(controlHost) {
371
+ const template = this.#resolveTemplate('select');
372
+ if (template === null) {
373
+ return createFallbackMountedControl(controlHost);
374
+ }
375
+ const fragment = template.content.cloneNode(true);
376
+ const control = fragment.querySelector('select');
377
+ if (control === null) {
378
+ return createFallbackMountedControl(controlHost);
379
+ }
380
+ return { fragment,
381
+ control,
382
+ className: resolveInitialControlClassName(control, controlHost),
383
+ invalidClassName: resolveInitialControlInvalidClassName(control, controlHost) };
384
+ }
385
+ #dispatchValueEvent(name) {
386
+ const detail = { value: this.status.draftValue,
387
+ isEmpty: this.status.isEmpty,
388
+ isValid: this.status.isValid,
389
+ errorMessage: this.status.errorMessage,
390
+ dirty: this.status.dirty };
391
+ this.dispatchEvent(new CustomEvent(name, { detail,
392
+ bubbles: true,
393
+ composed: true }));
394
+ }
395
+ #disposeControlBindings() {
396
+ this.#controlChangeListener?.();
397
+ this.#controlChangeListener = null;
398
+ this.#control = null;
399
+ this.#controlBaseClassName = '';
400
+ this.#controlInvalidClassName = null;
401
+ this.#controlTemplateDispose?.();
402
+ this.#controlTemplateDispose = null;
403
+ }
404
+ };
405
+ __decorate([
406
+ property({ attribute: false })
407
+ ], Select.prototype, "label", null);
408
+ __decorate([
409
+ property({ attribute: false })
410
+ ], Select.prototype, "description", null);
411
+ __decorate([
412
+ property({ attribute: false })
413
+ ], Select.prototype, "validator", null);
414
+ __decorate([
415
+ property({ attribute: false })
416
+ ], Select.prototype, "theme", null);
417
+ __decorate([
418
+ property({ attribute: false })
419
+ ], Select.prototype, "value", null);
420
+ __decorate([
421
+ property({ attribute: false })
422
+ ], Select.prototype, "placeholder", null);
423
+ __decorate([
424
+ property({ attribute: false })
425
+ ], Select.prototype, "items", null);
426
+ __decorate([
427
+ property({ attribute: false })
428
+ ], Select.prototype, "disabled", null);
429
+ __decorate([
430
+ property({ attribute: false })
431
+ ], Select.prototype, "controlClassName", null);
432
+ Select = __decorate([
433
+ customElement('asljs-select')
434
+ ], Select);
435
+ export { Select };
436
+ let nextSelectId = 1;
437
+ function normalizeText(value) {
438
+ return value ?? '';
439
+ }
440
+ function normalizeOptionalText(value) {
441
+ if (value === null
442
+ || value === undefined
443
+ || value === '') {
444
+ return null;
445
+ }
446
+ return value;
447
+ }
448
+ function normalizeItems(items) {
449
+ return items
450
+ .map(item => ({ value: item.value,
451
+ label: item.label,
452
+ disabled: item.disabled ?? false }))
453
+ .filter(item => item.label.trim() !== '');
454
+ }
455
+ function resolveAriaDescribedBy(model) {
456
+ const ids = [];
457
+ if (!model.hideDescription) {
458
+ ids.push(model.descriptionId);
459
+ }
460
+ if (!model.hideError) {
461
+ ids.push(model.errorId);
462
+ }
463
+ return ids.join(' ');
464
+ }
465
+ function cloneNamedTemplate(host, slotName) {
466
+ const element = host.querySelector(`template[data-slot="${slotName}"]`);
467
+ if (element === null) {
468
+ return null;
469
+ }
470
+ const templateElement = element;
471
+ const clonedTemplate = document.createElement('template');
472
+ clonedTemplate.content.append(templateElement.content.cloneNode(true));
473
+ return clonedTemplate;
474
+ }
475
+ function resolveInitialControlClassName(control, controlHost) {
476
+ return control.className
477
+ || controlHost.getAttribute('data-control-class')
478
+ || '';
479
+ }
480
+ function resolveInitialControlInvalidClassName(control, controlHost) {
481
+ return control.getAttribute('data-control-invalid-class')
482
+ ?? controlHost.getAttribute('data-control-invalid-class')
483
+ ?? null;
484
+ }
485
+ function createDefaultSelectTemplate(slotName) {
486
+ if (slotName === 'template') {
487
+ return createTemplate();
488
+ }
489
+ const template = document.createElement('template');
490
+ template.innerHTML = '<select></select>';
491
+ return template;
492
+ }
493
+ function createFallbackMountedControl(controlHost) {
494
+ const fragment = document.createDocumentFragment();
495
+ const control = document.createElement('select');
496
+ fragment.append(control);
497
+ return { fragment,
498
+ control,
499
+ className: resolveInitialControlClassName(control, controlHost),
500
+ invalidClassName: resolveInitialControlInvalidClassName(control, controlHost) };
501
+ }
502
+ function joinClassNames(...classNames) {
503
+ return classNames
504
+ .flatMap(className => className.split(/\s+/))
505
+ .map(className => className.trim())
506
+ .filter(className => className !== '')
507
+ .join(' ');
508
+ }
@@ -0,0 +1,6 @@
1
+ import { type BindDataModelOptions } from "asljs-data-binding";
2
+ import type { SelectTemplateModel as TemplateModel } from "./select.js";
3
+ /** The markup inside the template's <template> element. */
4
+ export declare const html = "\n <div>\n <label\n data-bind-text=\"label\"\n data-bind-prop-hidden=\"hideLabel\"\n data-bind-for=\"inputId\"></label>\n <div data-role=\"control-host\"></div>\n <div\n data-bind-text=\"description\"\n data-bind-prop-hidden=\"hideDescription\"\n data-bind-prop-id=\"descriptionId\"></div>\n <div\n data-bind-text=\"errorMessage\"\n data-bind-prop-hidden=\"hideError\"\n data-bind-prop-id=\"errorId\"></div>\n </div>\n";
5
+ export declare function createTemplate(doc?: Document): HTMLTemplateElement;
6
+ export declare function bindTemplate(root: ParentNode, model: TemplateModel, options?: BindDataModelOptions): () => boolean;
@@ -0,0 +1,26 @@
1
+ // Generated by data-bind-compile from select.tpl.html. Do not edit.
2
+ import { bindDataModel } from "asljs-data-binding";
3
+ /** The markup inside the template's <template> element. */
4
+ export const html = "\n <div>\n <label\n data-bind-text=\"label\"\n data-bind-prop-hidden=\"hideLabel\"\n data-bind-for=\"inputId\"></label>\n <div data-role=\"control-host\"></div>\n <div\n data-bind-text=\"description\"\n data-bind-prop-hidden=\"hideDescription\"\n data-bind-prop-id=\"descriptionId\"></div>\n <div\n data-bind-text=\"errorMessage\"\n data-bind-prop-hidden=\"hideError\"\n data-bind-prop-id=\"errorId\"></div>\n </div>\n";
5
+ export function createTemplate(doc = document) {
6
+ const template = doc.createElement('template');
7
+ template.innerHTML = html;
8
+ return template;
9
+ }
10
+ export function bindTemplate(root, model, options) {
11
+ return bindDataModel(root, model, options);
12
+ }
13
+ // Never called: each statement checks one binding against the model and
14
+ // element types, and names the template line it comes from.
15
+ function check(model) {
16
+ void model.label; // select.tpl.html:4:12 data-bind-text
17
+ void model.hideLabel; // select.tpl.html:5:12 data-bind-prop-hidden
18
+ void model.inputId; // select.tpl.html:6:12 data-bind-for
19
+ void model.description; // select.tpl.html:9:10 data-bind-text
20
+ void model.hideDescription; // select.tpl.html:10:10 data-bind-prop-hidden
21
+ void model.descriptionId; // select.tpl.html:11:10 data-bind-prop-id
22
+ void model.errorMessage; // select.tpl.html:13:10 data-bind-text
23
+ void model.hideError; // select.tpl.html:14:10 data-bind-prop-hidden
24
+ void model.errorId; // select.tpl.html:15:10 data-bind-prop-id
25
+ }
26
+ void check;
@@ -0,0 +1,12 @@
1
+ import { type ComponentsTheme, type ThemeTemplateValue } from 'asljs-ui';
2
+ export interface SelectThemeDefinition {
3
+ template?: ThemeTemplateValue;
4
+ select?: ThemeTemplateValue;
5
+ }
6
+ declare module 'asljs-ui' {
7
+ interface ComponentsTheme {
8
+ select?: SelectThemeDefinition;
9
+ }
10
+ }
11
+ /** The Bootstrap select section. */
12
+ export declare function createBootstrapSelectTheme(): ComponentsTheme;
package/dist/theme.js ADDED
@@ -0,0 +1,9 @@
1
+ import { html as selectTemplate } from './bootstrap-select.tpl.js';
2
+ /** The Bootstrap select section. */
3
+ export function createBootstrapSelectTheme() {
4
+ return { select: { template: selectTemplate,
5
+ select: `
6
+ <select class="form-select"
7
+ data-control-invalid-class="is-invalid"></select>
8
+ ` } };
9
+ }
package/docs/Select.md ADDED
@@ -0,0 +1,68 @@
1
+ # Select
2
+
3
+ `asljs-select` is a labelled drop-down with validation.
4
+
5
+ ## Properties
6
+
7
+ - `items` — explicit `{ value, label, disabled? }` entries
8
+ - `value` — the external set/reset selection
9
+ - `label`
10
+ - `description`
11
+ - `placeholder` — when set, an empty prompt option is shown first
12
+ - `validator` — returns an error message or `null`
13
+ - `disabled`
14
+ - `controlClassName` — a class for the inner native `select`, for host CSS
15
+ - `theme`
16
+ - local `template[data-slot="template"]` and `template[data-slot="select"]`
17
+
18
+ As with [text input][TXT], `value` is a set/reset input. The user's selection is
19
+ kept in `draftValue`, reflected in the observable `status` object, and emitted
20
+ through `input` and `change` events. Their detail is:
21
+
22
+ ```ts
23
+ {
24
+ value: string;
25
+ isEmpty: boolean;
26
+ isValid: boolean;
27
+ errorMessage: string | null;
28
+ dirty: boolean;
29
+ }
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import 'asljs-ui-select';
36
+
37
+ const select =
38
+ document.createElement('asljs-select') as HTMLElement & {
39
+ label: string | null;
40
+ items: Array<{ value: string; label: string; disabled?: boolean }>;
41
+ value: string | null;
42
+ placeholder: string | null;
43
+ };
44
+
45
+ select.label = 'Size';
46
+ select.placeholder = 'Choose a size';
47
+ select.items =
48
+ [ { value: 's', label: 'Small' },
49
+ { value: 'm', label: 'Medium' },
50
+ { value: 'l', label: 'Large', disabled: true } ];
51
+ select.value = 'm';
52
+
53
+ select.addEventListener(
54
+ 'change',
55
+ event => {
56
+ const detail =
57
+ (event as CustomEvent<{ value: string; isValid: boolean }>).detail;
58
+
59
+ console.log(detail.value, detail.isValid);
60
+ });
61
+ ```
62
+
63
+ Layout and control markup can be overridden with local slot templates or the
64
+ `select` section of a theme, as for text input; `createBootstrapSelectTheme()`
65
+ supplies Bootstrap form markup. See [Theming][THM].
66
+
67
+ [THM]: ../../core/docs/Theming.md
68
+ [TXT]: <../../text-input/docs/Text Input.md>
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "asljs-ui-select",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A drop-down web component with validation.",
5
+ "type": "module",
6
+ "asljs": {
7
+ "type": "lib"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist/**",
19
+ "README.md",
20
+ "LICENSE.md",
21
+ "docs/**"
22
+ ],
23
+ "scripts": {
24
+ "flint": "toolkit flint",
25
+ "clean": "toolkit clean",
26
+ "build": "npm run codegen --if-present && tsc -p tsconfig.build.json",
27
+ "build:dist": "npm run codegen --if-present && tsc -p tsconfig.dist.json",
28
+ "typecheck": "npm run codegen --if-present && tsc -p tsconfig.build.json --noEmit",
29
+ "guard:clean-git": "toolkit ensure-clean-working-directory",
30
+ "prepack": "npm run clean && npm run build:dist && npm run build && npm run test && npm run guard:clean-git",
31
+ "postpublish": "toolkit tag-release-revision",
32
+ "test": "node --test --enable-source-maps \"build/**/*.test.js\"",
33
+ "test:watch": "node --watch --test \"build/**/*.test.js\"",
34
+ "coverage": "npm run build && node --test --experimental-test-coverage --enable-source-maps --test-coverage-exclude=\"**/*.test.js\" \"build/**/*.test.js\"",
35
+ "release:patch": "toolkit release-patch",
36
+ "all": "npm run clean && npm run build:dist && npm run flint && npm run typecheck && npm run build && npm run test",
37
+ "codegen": "data-bind-compile"
38
+ },
39
+ "keywords": [
40
+ "web-components",
41
+ "lit",
42
+ "ui",
43
+ "select",
44
+ "form"
45
+ ],
46
+ "author": "\"Alex Netkachov\" <alex.netkachov@gmail.com>",
47
+ "license": "SEE LICENSE IN LICENSE.md",
48
+ "homepage": "https://github.com/AlexandriteSoftware/asljs#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/AlexandriteSoftware/asljs/issues"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/AlexandriteSoftware/asljs.git"
55
+ },
56
+ "dependencies": {
57
+ "asljs-data-binding": "^0.2.5",
58
+ "asljs-observable": "^0.5.5",
59
+ "asljs-ui": "^0.1.1",
60
+ "lit": "^3.3.1"
61
+ },
62
+ "devDependencies": {
63
+ "asljs-testing": "^0.1.1",
64
+ "asljs-toolkit": "^0.1.0"
65
+ }
66
+ }