bank20baht-ui 0.9.0 → 0.11.0
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/README.md +20 -18
- package/dist/components/baht-form.js +1 -0
- package/dist/components/primitives/baht-modal.d.ts +4 -0
- package/dist/components/primitives/baht-modal.js +25 -21
- package/dist/components/primitives/baht-tags.d.ts +95 -0
- package/dist/components/primitives/baht-tags.js +319 -0
- package/dist/components/primitives/baht-typeahead.js +52 -54
- package/dist/components/primitives/index.d.ts +1 -0
- package/dist/components/primitives/listbox.d.ts +6 -0
- package/dist/components/primitives/listbox.js +16 -0
- package/dist/components/primitives/tag-map.js +2 -1
- package/dist/core/defaults.js +1 -0
- package/dist/core/display.js +1 -0
- package/dist/core/i18n.d.ts +5 -0
- package/dist/core/i18n.js +18 -8
- package/dist/core/index.d.ts +1 -1
- package/dist/core/types.d.ts +16 -1
- package/dist/core/validators.d.ts +15 -1
- package/dist/core/validators.js +86 -58
- package/dist/css/components.css +83 -0
- package/dist/custom-elements.json +1408 -200
- package/dist/engine.js +37 -36
- package/dist/index.js +175 -172
- package/dist/overlay.d.ts +1 -1
- package/dist/overlay.js +20 -20
- package/dist/react.d.ts +1 -0
- package/dist/register.d.ts +1 -0
- package/dist/register.js +1 -0
- package/dist/ssr.js +3 -2
- package/dist/tokens.css +83 -0
- package/dist/vscode.html-custom-data.json +26 -1
- package/dist/web-types.json +60 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -154,7 +154,7 @@ el.value = JSON.parse(localStorage.getItem(key) ?? '{}'); // restore on mount
|
|
|
154
154
|
// clear the key after the server accepts the submission
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
The `/builder` page of the docs site (`bun run --filter docs dev`) shows the same flow live.
|
|
158
158
|
|
|
159
159
|
### i18n — Thai (or any) built-in strings
|
|
160
160
|
|
|
@@ -261,7 +261,7 @@ near-black ink (`#171717`) on white, hairline neutral borders, 44px controls, on
|
|
|
261
261
|
(`#444FBF`), status as tint pills, hand-drawn koboyo spot illustrations for empty /
|
|
262
262
|
error / upload states, IBM Plex Sans Thai / IBM Plex Mono (load the
|
|
263
263
|
fonts yourself), borders-over-shadows, "paper" easing — plus an opt-in dark theme
|
|
264
|
-
(`<html data-baht-theme="dark">`).
|
|
264
|
+
(`<html data-baht-theme="dark">`). 58 elements:
|
|
265
265
|
|
|
266
266
|
```html
|
|
267
267
|
<baht-button variant="secondary" size="sm">Cancel</baht-button>
|
|
@@ -275,10 +275,13 @@ fonts yourself), borders-over-shadows, "paper" easing — plus an opt-in dark th
|
|
|
275
275
|
- **Feedback** — `baht-modal` · `baht-toast` · `baht-alert` · `baht-tooltip` · `baht-progress` ·
|
|
276
276
|
`baht-spinner` · `baht-skeleton`
|
|
277
277
|
- **Inputs** — `baht-rating` · `baht-range` · `baht-otp` · `baht-typeahead` · `baht-reference-picker` ·
|
|
278
|
-
`baht-keyvalue-editor` (+ the
|
|
279
|
-
|
|
280
|
-
- **
|
|
281
|
-
`baht-
|
|
278
|
+
`baht-keyvalue-editor` · `baht-upload` (+ the 15 form field primitives). Typed fields debounce keystrokes
|
|
279
|
+
(150 ms, `debounce`); `dropdown` renders its own listbox, never the OS picker.
|
|
280
|
+
- **Stats & charts** — `baht-stat` · `baht-stat-chart` · `baht-meter` · `baht-sparkline` · `baht-bar-chart` ·
|
|
281
|
+
`baht-line-chart` · `baht-donut-chart` · `baht-heatmap` · `baht-progress-ring`
|
|
282
|
+
- **Structure & nav** — `baht-card` · `baht-fieldset` · `baht-timeline` · `baht-accordion` · `baht-steps` ·
|
|
283
|
+
`baht-link` · `baht-breadcrumbs` · `baht-avatar` · `baht-code` · `baht-empty-state` · `baht-error-state` ·
|
|
284
|
+
`baht-loading-overlay`
|
|
282
285
|
|
|
283
286
|
## Theming — design tokens + light DOM
|
|
284
287
|
|
|
@@ -305,7 +308,7 @@ Full reference: [docs/theming.md](./docs/theming.md).
|
|
|
305
308
|
// 1. per-element subpath — one module per tag, plus only what it imports
|
|
306
309
|
import 'bank20baht-ui/components/button'; // <baht-button>
|
|
307
310
|
import { BahtTable } from 'bank20baht-ui/components/table';
|
|
308
|
-
import 'bank20baht-ui/components/form'; // <baht-form> + the
|
|
311
|
+
import 'bank20baht-ui/components/form'; // <baht-form> + the 15 field primitives
|
|
309
312
|
import 'bank20baht-ui/components/workflow';
|
|
310
313
|
|
|
311
314
|
// 2. by tag list — each tag is a dynamic import, fetched on demand
|
|
@@ -344,7 +347,7 @@ no hand-written type declarations in your app:
|
|
|
344
347
|
| **Angular** | template binding on properties/events | `CUSTOM_ELEMENTS_SCHEMA`, see below |
|
|
345
348
|
| **Vue 3** | keep Vue from treating `baht-*` as components | `isCustomElement`, see below |
|
|
346
349
|
| **Phoenix / Rails / plain HTML** | it's just HTML — works as-is | none |
|
|
347
|
-
| **Tooling** (Storybook, docgen, custom) | machine-readable API of all
|
|
350
|
+
| **Tooling** (Storybook, docgen, custom) | machine-readable API of all 58 elements | read `dist/custom-elements.json` ([CEM standard](https://github.com/webcomponents/custom-elements-manifest)) |
|
|
348
351
|
|
|
349
352
|
**VS Code** — `.vscode/settings.json`:
|
|
350
353
|
|
|
@@ -400,21 +403,20 @@ import 'bank20baht-ui';
|
|
|
400
403
|
`@lit-labs/ssr` is an optional peer dependency (server only). Full guide, limitations
|
|
401
404
|
(light-DOM children, ids, i18n locale) and troubleshooting: [docs/ssr.md](./docs/ssr.md).
|
|
402
405
|
|
|
403
|
-
## Docs
|
|
406
|
+
## Docs
|
|
404
407
|
|
|
405
|
-
- **Per-component docs**: [`docs/`](./docs/README.md) — one .md per
|
|
406
|
-
guides (vanilla / React / Angular).
|
|
407
|
-
- **
|
|
408
|
-
- **
|
|
409
|
-
repo root — same workflow JSON in three hosts.
|
|
408
|
+
- **Per-component docs**: [`docs/`](./docs/README.md) — one .md per element + framework
|
|
409
|
+
guides (vanilla / React / Angular) + theming + SSR.
|
|
410
|
+
- **Docs site with live specimens**: https://bahtui-docs.pages.dev (source: `apps/docs`).
|
|
411
|
+
- **For agents**: the `bahtui-mcp` server (`apps/mcp`) serves these docs and runs the engine on your JSON.
|
|
410
412
|
|
|
411
|
-
## Field types (
|
|
413
|
+
## Field types (17)
|
|
412
414
|
|
|
413
|
-
`text · textarea · number · email · password · dropdown · multiselect ·
|
|
414
|
-
file (
|
|
415
|
+
`text · textarea · number · currency · email · password · dropdown · multiselect · tags · checkbox · radio ·
|
|
416
|
+
toggle · date · file · repeater (one level of rows) · label (static) · section (divider)`
|
|
415
417
|
|
|
416
418
|
Each field descriptor: `{ order, type, key, label, placeholder, width: '1/4'|'1/2'|'3/4'|'full',
|
|
417
|
-
value, disabled, required, options, max_length, min, max, validators[], formatter }`.
|
|
419
|
+
value, disabled, required, options | options_from, max_length, min, max, validators[], formatter, flag }`.
|
|
418
420
|
|
|
419
421
|
## Visual Builder
|
|
420
422
|
|
|
@@ -17,6 +17,7 @@ import "./primitives/baht-number.js";
|
|
|
17
17
|
import "./primitives/baht-currency.js";
|
|
18
18
|
import "./primitives/baht-select.js";
|
|
19
19
|
import "./primitives/baht-multiselect.js";
|
|
20
|
+
import "./primitives/baht-tags.js";
|
|
20
21
|
import "./primitives/baht-checkbox.js";
|
|
21
22
|
import "./primitives/baht-radio.js";
|
|
22
23
|
import "./primitives/baht-toggle.js";
|
|
@@ -16,6 +16,10 @@ export declare class BahtModal extends BahtElement {
|
|
|
16
16
|
/** One muted line under the heading; also becomes the dialog's aria-describedby. */
|
|
17
17
|
description: string;
|
|
18
18
|
showClose: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Scrim click requests close. Off by default: a stray click outside the panel
|
|
21
|
+
* must not throw away what the user typed — close via ×, Escape or your buttons.
|
|
22
|
+
*/
|
|
19
23
|
closeOnScrim: boolean;
|
|
20
24
|
private bodyEl;
|
|
21
25
|
private get titleId();
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { nothing as a, html as h } from "lit";
|
|
2
|
-
import { property as c, query as f, state as
|
|
3
|
-
import { boolOff as
|
|
2
|
+
import { property as c, query as f, state as m, customElement as b } from "lit/decorators.js";
|
|
3
|
+
import { boolOff as y, BahtElement as v, hasDom as p, adoptLightChildren as u } from "../base.js";
|
|
4
4
|
import { afterExit as g } from "../motion.js";
|
|
5
5
|
import { t as $ } from "../../core/i18n.js";
|
|
6
6
|
import { renderIllustration as k } from "./illustrations.js";
|
|
7
|
-
var C = Object.defineProperty, S = Object.getOwnPropertyDescriptor,
|
|
8
|
-
for (var o = n > 1 ? void 0 : n ? S(i, e) : i,
|
|
9
|
-
(d = t[
|
|
7
|
+
var C = Object.defineProperty, S = Object.getOwnPropertyDescriptor, r = (t, i, e, n) => {
|
|
8
|
+
for (var o = n > 1 ? void 0 : n ? S(i, e) : i, l = t.length - 1, d; l >= 0; l--)
|
|
9
|
+
(d = t[l]) && (o = (n ? d(i, e, o) : d(o)) || o);
|
|
10
10
|
return n && o && C(i, e, o), o;
|
|
11
11
|
};
|
|
12
|
+
const E = {
|
|
13
|
+
fromAttribute: (t) => t !== null && t !== "false",
|
|
14
|
+
toAttribute: (t) => t ? "" : null
|
|
15
|
+
};
|
|
12
16
|
let s = class extends v {
|
|
13
17
|
constructor() {
|
|
14
|
-
super(...arguments), this.open = !1, this.heading = "", this.description = "", this.showClose = !0, this.closeOnScrim = !
|
|
18
|
+
super(...arguments), this.open = !1, this.heading = "", this.description = "", this.showClose = !0, this.closeOnScrim = !1, this.closing = !1, this.closeToken = 0, this.previousFocus = null, this.onKeydown = (t) => {
|
|
15
19
|
!this.open || s.openStack.at(-1) !== this || (t.key === "Escape" ? this.requestClose("escape") : t.key === "Tab" && this.trapFocus(t));
|
|
16
20
|
};
|
|
17
21
|
}
|
|
@@ -36,12 +40,12 @@ let s = class extends v {
|
|
|
36
40
|
e == null || e.focus(), t.preventDefault();
|
|
37
41
|
return;
|
|
38
42
|
}
|
|
39
|
-
const n = i[0], o = i[i.length - 1],
|
|
40
|
-
if (!this.contains(
|
|
43
|
+
const n = i[0], o = i[i.length - 1], l = document.activeElement;
|
|
44
|
+
if (!this.contains(l)) {
|
|
41
45
|
n.focus(), t.preventDefault();
|
|
42
46
|
return;
|
|
43
47
|
}
|
|
44
|
-
t.shiftKey && (
|
|
48
|
+
t.shiftKey && (l === n || l === e) ? (o.focus(), t.preventDefault()) : !t.shiftKey && l === o && (n.focus(), t.preventDefault());
|
|
45
49
|
}
|
|
46
50
|
connectedCallback() {
|
|
47
51
|
super.connectedCallback(), p() && document.addEventListener("keydown", this.onKeydown);
|
|
@@ -110,29 +114,29 @@ let s = class extends v {
|
|
|
110
114
|
}
|
|
111
115
|
};
|
|
112
116
|
s.openStack = [];
|
|
113
|
-
|
|
117
|
+
r([
|
|
114
118
|
c({ type: Boolean, reflect: !0 })
|
|
115
119
|
], s.prototype, "open", 2);
|
|
116
|
-
|
|
120
|
+
r([
|
|
117
121
|
c()
|
|
118
122
|
], s.prototype, "heading", 2);
|
|
119
|
-
|
|
123
|
+
r([
|
|
120
124
|
c()
|
|
121
125
|
], s.prototype, "description", 2);
|
|
122
|
-
|
|
123
|
-
c({ attribute: "show-close", converter:
|
|
126
|
+
r([
|
|
127
|
+
c({ attribute: "show-close", converter: y })
|
|
124
128
|
], s.prototype, "showClose", 2);
|
|
125
|
-
|
|
126
|
-
c({ attribute: "close-on-scrim", converter:
|
|
129
|
+
r([
|
|
130
|
+
c({ attribute: "close-on-scrim", converter: E })
|
|
127
131
|
], s.prototype, "closeOnScrim", 2);
|
|
128
|
-
|
|
132
|
+
r([
|
|
129
133
|
f(".baht-modal-body")
|
|
130
134
|
], s.prototype, "bodyEl", 2);
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
r([
|
|
136
|
+
m()
|
|
133
137
|
], s.prototype, "closing", 2);
|
|
134
|
-
s =
|
|
135
|
-
|
|
138
|
+
s = r([
|
|
139
|
+
b("baht-modal")
|
|
136
140
|
], s);
|
|
137
141
|
export {
|
|
138
142
|
s as BahtModal
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { PropertyValues, TemplateResult } from 'lit';
|
|
2
|
+
import { BahtFieldBase } from '../field-base.js';
|
|
3
|
+
/**
|
|
4
|
+
* Creatable chip input: value is `string[]` (unique, trimmed, in entry
|
|
5
|
+
* order). Type text + Enter or comma to add a chip; Backspace on an empty
|
|
6
|
+
* input removes the last chip; pasting "a, b, c" adds three at once.
|
|
7
|
+
*
|
|
8
|
+
* Suggestions come from `field.options` — the SAME list every other
|
|
9
|
+
* option-bearing field reads, already resolved by `<baht-form>` when the
|
|
10
|
+
* descriptor uses `options_from` (spec 01 §1: dynamic options are merged
|
|
11
|
+
* into `field.options` before the primitive ever sees them, so this
|
|
12
|
+
* component needs no fetcher of its own). They filter as you type
|
|
13
|
+
* (case-insensitive, prefix matches first) and exclude tags already chosen.
|
|
14
|
+
* Free text becomes a chip unless `field.allow_new === false`, in which case
|
|
15
|
+
* only a suggestion's `key` (matched case-insensitively) can become one.
|
|
16
|
+
*
|
|
17
|
+
* el.field = { type: 'tags', key: 'labels', options: [...], max_items: 5 };
|
|
18
|
+
* el.addEventListener('baht-input', (e) => (draft.labels = e.detail.value));
|
|
19
|
+
*
|
|
20
|
+
* ARIA: APG editable combobox with list autocomplete, same pattern as
|
|
21
|
+
* <baht-typeahead> — the input is the combobox, the chips sit before it in
|
|
22
|
+
* the same control, `aria-activedescendant` walks the listbox.
|
|
23
|
+
*/
|
|
24
|
+
export declare class BahtTags extends BahtFieldBase {
|
|
25
|
+
private inputText;
|
|
26
|
+
private open;
|
|
27
|
+
private activeIndex;
|
|
28
|
+
/** Transient reason the last chip attempt was refused (max_length /
|
|
29
|
+
* max_items / allow_new / duplicate) — cleared on the next successful
|
|
30
|
+
* add or edit, never persisted, never blocks anything by itself. */
|
|
31
|
+
private hint;
|
|
32
|
+
private inputEl;
|
|
33
|
+
private readonly onDocMousedown;
|
|
34
|
+
disconnectedCallback(): void;
|
|
35
|
+
protected willUpdate(changed: PropertyValues<this>): void;
|
|
36
|
+
/** The host value as canonical tags — a store/host can hand in anything
|
|
37
|
+
* (`[1, null]`), and a non-string entry must not crash the render. */
|
|
38
|
+
private get tags();
|
|
39
|
+
/** A chip shows the matching option's label when the tag happens to be one
|
|
40
|
+
* (case-insensitive) — same fallback the readonly display (`displayValue`)
|
|
41
|
+
* already applies; free text with no match shows as typed. */
|
|
42
|
+
private chipLabel;
|
|
43
|
+
private get maxLength();
|
|
44
|
+
private get allowNew();
|
|
45
|
+
private get suggestions();
|
|
46
|
+
/** Same id the base label's `for` points at — clicking the field label
|
|
47
|
+
* focuses the input natively, no click handler of our own needed. */
|
|
48
|
+
private get inputId();
|
|
49
|
+
private get listboxId();
|
|
50
|
+
private get hintId();
|
|
51
|
+
/** Base `describedBy` (error/help) plus the transient refusal hint, when
|
|
52
|
+
* one is showing. */
|
|
53
|
+
private get controlDescribedBy();
|
|
54
|
+
private optionId;
|
|
55
|
+
/** Why `trimmed` can't join `current` right now, or null when it can —
|
|
56
|
+
* same rules bank20baht-validator re-checks server-side. */
|
|
57
|
+
private rejectionFor;
|
|
58
|
+
/**
|
|
59
|
+
* `current` + one more chip, or the reason it was refused — null only for
|
|
60
|
+
* blank input (nothing worth reporting). Pure (no emit) so `addMany` can
|
|
61
|
+
* fold several candidates into ONE next array before telling the host,
|
|
62
|
+
* instead of racing `this.value` across several back-to-back emits.
|
|
63
|
+
*/
|
|
64
|
+
private nextWithTag;
|
|
65
|
+
private hintFor;
|
|
66
|
+
/** Attempt to add one chip; returns whether it landed. A refusal shows a
|
|
67
|
+
* brief inline hint instead of failing silently. */
|
|
68
|
+
private addTag;
|
|
69
|
+
private removeTag;
|
|
70
|
+
/** Split pasted / bulk text on commas (and newlines) into separate chips,
|
|
71
|
+
* emitted as ONE array (see `nextWithTag`). Reports the last refusal only
|
|
72
|
+
* when NOTHING from the paste landed. */
|
|
73
|
+
private addMany;
|
|
74
|
+
private listen;
|
|
75
|
+
private close;
|
|
76
|
+
private highlight;
|
|
77
|
+
private commitTyped;
|
|
78
|
+
private pick;
|
|
79
|
+
private onInput;
|
|
80
|
+
private onEnter;
|
|
81
|
+
private onBackspace;
|
|
82
|
+
private onArrowDown;
|
|
83
|
+
private onArrowUp;
|
|
84
|
+
private onEscape;
|
|
85
|
+
private onKeydown;
|
|
86
|
+
private onPaste;
|
|
87
|
+
private onBlur;
|
|
88
|
+
protected updated(): void;
|
|
89
|
+
protected renderControl(): TemplateResult;
|
|
90
|
+
}
|
|
91
|
+
declare global {
|
|
92
|
+
interface HTMLElementTagNameMap {
|
|
93
|
+
'baht-tags': BahtTags;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { nothing as l, html as p } from "lit";
|
|
2
|
+
import { state as d, query as f, customElement as m } from "lit/decorators.js";
|
|
3
|
+
import { live as b } from "lit/directives/live.js";
|
|
4
|
+
import { BahtFieldBase as v } from "../field-base.js";
|
|
5
|
+
import { unlistenOutside as u, listenOutside as x, scrollOptionIntoView as w } from "./listbox.js";
|
|
6
|
+
import { t as r } from "../../core/i18n.js";
|
|
7
|
+
import { normalizeTagsValue as g, optionKeyLookup as I } from "../../core/validators.js";
|
|
8
|
+
var $ = Object.defineProperty, y = Object.getOwnPropertyDescriptor, c = (t, e, i, s) => {
|
|
9
|
+
for (var o = s > 1 ? void 0 : s ? y(e, i) : e, n = t.length - 1, a; n >= 0; n--)
|
|
10
|
+
(a = t[n]) && (o = (s ? a(e, i, o) : a(o)) || o);
|
|
11
|
+
return s && o && $(e, i, o), o;
|
|
12
|
+
};
|
|
13
|
+
const T = 8;
|
|
14
|
+
let h = class extends v {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(...arguments), this.inputText = "", this.open = !1, this.activeIndex = -1, this.hint = null, this.onDocMousedown = (t) => {
|
|
17
|
+
this.contains(t.target) || this.close();
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
disconnectedCallback() {
|
|
21
|
+
u(this.onDocMousedown), super.disconnectedCallback();
|
|
22
|
+
}
|
|
23
|
+
willUpdate(t) {
|
|
24
|
+
super.willUpdate(t);
|
|
25
|
+
const e = t.get("field");
|
|
26
|
+
t.has("field") && e !== void 0 && e.key !== this.field.key && (this.inputText = "", this.close());
|
|
27
|
+
}
|
|
28
|
+
/** The host value as canonical tags — a store/host can hand in anything
|
|
29
|
+
* (`[1, null]`), and a non-string entry must not crash the render. */
|
|
30
|
+
get tags() {
|
|
31
|
+
return g(this.value);
|
|
32
|
+
}
|
|
33
|
+
/** A chip shows the matching option's label when the tag happens to be one
|
|
34
|
+
* (case-insensitive) — same fallback the readonly display (`displayValue`)
|
|
35
|
+
* already applies; free text with no match shows as typed. */
|
|
36
|
+
chipLabel(t) {
|
|
37
|
+
const e = t.toLowerCase(), i = (this.field.options ?? []).find((s) => s.key.toLowerCase() === e);
|
|
38
|
+
return (i == null ? void 0 : i.label) ?? t;
|
|
39
|
+
}
|
|
40
|
+
get maxLength() {
|
|
41
|
+
return this.field.max_length ?? 32;
|
|
42
|
+
}
|
|
43
|
+
get allowNew() {
|
|
44
|
+
return this.field.allow_new !== !1;
|
|
45
|
+
}
|
|
46
|
+
get suggestions() {
|
|
47
|
+
const t = this.field.options ?? [], e = new Set(this.tags.map((n) => n.toLowerCase())), i = this.inputText.trim().toLowerCase(), s = [], o = [];
|
|
48
|
+
for (const n of t) {
|
|
49
|
+
if (e.has(n.key.toLowerCase())) continue;
|
|
50
|
+
if (!i) {
|
|
51
|
+
s.push(n);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const a = n.label.toLowerCase();
|
|
55
|
+
a.startsWith(i) || n.key.toLowerCase().startsWith(i) ? s.push(n) : (a.includes(i) || n.key.toLowerCase().includes(i)) && o.push(n);
|
|
56
|
+
}
|
|
57
|
+
return [...s, ...o].slice(0, T);
|
|
58
|
+
}
|
|
59
|
+
/** Same id the base label's `for` points at — clicking the field label
|
|
60
|
+
* focuses the input natively, no click handler of our own needed. */
|
|
61
|
+
get inputId() {
|
|
62
|
+
return this.controlId;
|
|
63
|
+
}
|
|
64
|
+
get listboxId() {
|
|
65
|
+
return `${this.inputId}-list`;
|
|
66
|
+
}
|
|
67
|
+
get hintId() {
|
|
68
|
+
return `${this.inputId}-hint`;
|
|
69
|
+
}
|
|
70
|
+
/** Base `describedBy` (error/help) plus the transient refusal hint, when
|
|
71
|
+
* one is showing. */
|
|
72
|
+
get controlDescribedBy() {
|
|
73
|
+
const t = this.describedBy, e = [t === l ? "" : t, this.hint ? this.hintId : ""].filter(Boolean);
|
|
74
|
+
return e.length > 0 ? e.join(" ") : l;
|
|
75
|
+
}
|
|
76
|
+
optionId(t) {
|
|
77
|
+
return `${this.listboxId}-${t}`;
|
|
78
|
+
}
|
|
79
|
+
/** Why `trimmed` can't join `current` right now, or null when it can —
|
|
80
|
+
* same rules bank20baht-validator re-checks server-side. */
|
|
81
|
+
rejectionFor(t, e, i) {
|
|
82
|
+
return e.length > this.maxLength ? "tooLong" : t.some((s) => s.toLowerCase() === i) ? "duplicate" : !this.allowNew && !new Set((this.field.options ?? []).map((o) => o.key.toLowerCase())).has(i) ? "notAllowed" : this.field.max_items !== void 0 && t.length >= this.field.max_items ? "maxItems" : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `current` + one more chip, or the reason it was refused — null only for
|
|
86
|
+
* blank input (nothing worth reporting). Pure (no emit) so `addMany` can
|
|
87
|
+
* fold several candidates into ONE next array before telling the host,
|
|
88
|
+
* instead of racing `this.value` across several back-to-back emits.
|
|
89
|
+
*/
|
|
90
|
+
nextWithTag(t, e) {
|
|
91
|
+
const i = e.trim();
|
|
92
|
+
if (!i) return null;
|
|
93
|
+
const s = i.toLowerCase(), o = this.field.options ?? [], n = I(o).get(s) ?? i, a = this.rejectionFor(t, n, s);
|
|
94
|
+
return a ? { reason: a } : { tags: g([...t, n], o) };
|
|
95
|
+
}
|
|
96
|
+
hintFor(t) {
|
|
97
|
+
switch (t) {
|
|
98
|
+
case "tooLong":
|
|
99
|
+
return r("tagTooLong", { n: this.maxLength });
|
|
100
|
+
case "duplicate":
|
|
101
|
+
return r("tagDuplicate");
|
|
102
|
+
case "notAllowed":
|
|
103
|
+
return r("tagNotAllowed");
|
|
104
|
+
case "maxItems":
|
|
105
|
+
return r("maxItems", { n: this.field.max_items });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Attempt to add one chip; returns whether it landed. A refusal shows a
|
|
109
|
+
* brief inline hint instead of failing silently. */
|
|
110
|
+
addTag(t) {
|
|
111
|
+
const e = this.nextWithTag(this.tags, t);
|
|
112
|
+
return e ? "reason" in e ? (this.hint = this.hintFor(e.reason), !1) : (this.hint = null, this.emitInput(e.tags), !0) : !1;
|
|
113
|
+
}
|
|
114
|
+
removeTag(t) {
|
|
115
|
+
var e;
|
|
116
|
+
this.emitInput(this.tags.filter((i) => i !== t)), (e = this.inputEl) == null || e.focus();
|
|
117
|
+
}
|
|
118
|
+
/** Split pasted / bulk text on commas (and newlines) into separate chips,
|
|
119
|
+
* emitted as ONE array (see `nextWithTag`). Reports the last refusal only
|
|
120
|
+
* when NOTHING from the paste landed. */
|
|
121
|
+
addMany(t) {
|
|
122
|
+
let e = this.tags, i = !1, s = null;
|
|
123
|
+
for (const o of t.split(/[,\n]/)) {
|
|
124
|
+
const n = this.nextWithTag(e, o);
|
|
125
|
+
if (n) {
|
|
126
|
+
if ("reason" in n) {
|
|
127
|
+
s = n.reason;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
e = n.tags, i = !0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
i ? (this.hint = null, this.emitInput(e)) : s && (this.hint = this.hintFor(s));
|
|
134
|
+
}
|
|
135
|
+
listen() {
|
|
136
|
+
x(this.onDocMousedown);
|
|
137
|
+
}
|
|
138
|
+
close() {
|
|
139
|
+
u(this.onDocMousedown), this.open = !1, this.activeIndex = -1;
|
|
140
|
+
}
|
|
141
|
+
highlight(t) {
|
|
142
|
+
this.activeIndex !== t && (this.activeIndex = t);
|
|
143
|
+
}
|
|
144
|
+
commitTyped() {
|
|
145
|
+
this.addTag(this.inputText) && (this.inputText = ""), this.close();
|
|
146
|
+
}
|
|
147
|
+
pick(t) {
|
|
148
|
+
var e;
|
|
149
|
+
this.addTag(t.key), this.inputText = "", this.close(), (e = this.inputEl) == null || e.focus();
|
|
150
|
+
}
|
|
151
|
+
onInput(t) {
|
|
152
|
+
this.inputText = t.target.value, this.activeIndex = -1, this.open = !0, this.hint = null, this.listen();
|
|
153
|
+
}
|
|
154
|
+
onEnter(t) {
|
|
155
|
+
t.preventDefault();
|
|
156
|
+
const e = this.open && this.activeIndex >= 0 ? this.suggestions[this.activeIndex] : void 0;
|
|
157
|
+
e ? this.pick(e) : this.commitTyped();
|
|
158
|
+
}
|
|
159
|
+
onBackspace(t) {
|
|
160
|
+
this.inputText !== "" || this.tags.length === 0 || (t.preventDefault(), this.removeTag(this.tags[this.tags.length - 1]));
|
|
161
|
+
}
|
|
162
|
+
onArrowDown(t) {
|
|
163
|
+
if (t.preventDefault(), !this.open) {
|
|
164
|
+
this.open = !0, this.listen();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const e = this.suggestions.length;
|
|
168
|
+
e > 0 && (this.activeIndex = (this.activeIndex + 1) % e);
|
|
169
|
+
}
|
|
170
|
+
onArrowUp(t) {
|
|
171
|
+
t.preventDefault();
|
|
172
|
+
const e = this.suggestions.length;
|
|
173
|
+
this.open && e > 0 && (this.activeIndex = (this.activeIndex - 1 + e) % e);
|
|
174
|
+
}
|
|
175
|
+
onEscape(t) {
|
|
176
|
+
this.open && (t.preventDefault(), this.close());
|
|
177
|
+
}
|
|
178
|
+
onKeydown(t) {
|
|
179
|
+
if (!(t.isComposing || t.keyCode === 229))
|
|
180
|
+
switch (t.key) {
|
|
181
|
+
case ",":
|
|
182
|
+
t.preventDefault(), this.commitTyped();
|
|
183
|
+
break;
|
|
184
|
+
case "Enter":
|
|
185
|
+
this.onEnter(t);
|
|
186
|
+
break;
|
|
187
|
+
case "Backspace":
|
|
188
|
+
this.onBackspace(t);
|
|
189
|
+
break;
|
|
190
|
+
case "ArrowDown":
|
|
191
|
+
this.onArrowDown(t);
|
|
192
|
+
break;
|
|
193
|
+
case "ArrowUp":
|
|
194
|
+
this.onArrowUp(t);
|
|
195
|
+
break;
|
|
196
|
+
case "Escape":
|
|
197
|
+
this.onEscape(t);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
onPaste(t) {
|
|
202
|
+
var a;
|
|
203
|
+
const e = ((a = t.clipboardData) == null ? void 0 : a.getData("text")) ?? "";
|
|
204
|
+
if (!e.includes(",") && !e.includes(`
|
|
205
|
+
`)) return;
|
|
206
|
+
t.preventDefault();
|
|
207
|
+
const i = t.target, s = i.value, o = i.selectionStart ?? s.length, n = i.selectionEnd ?? o;
|
|
208
|
+
this.addMany(s.slice(0, o) + e + s.slice(n)), this.inputText = "";
|
|
209
|
+
}
|
|
210
|
+
onBlur() {
|
|
211
|
+
this.commitTyped();
|
|
212
|
+
}
|
|
213
|
+
updated() {
|
|
214
|
+
w(this, this.open, this.activeIndex, this.optionId(this.activeIndex));
|
|
215
|
+
}
|
|
216
|
+
renderControl() {
|
|
217
|
+
const t = this.open && this.suggestions.length > 0, e = this.disabled || this.readonly;
|
|
218
|
+
return p`<div class="baht-tags${this.open ? " baht-tags--open" : ""}">
|
|
219
|
+
<div class="baht-control baht-tags-control">
|
|
220
|
+
${this.tags.map(
|
|
221
|
+
(i) => p`<span class="baht-tag-chip">
|
|
222
|
+
<span class="baht-tag-chip-label">${this.chipLabel(i)}</span>
|
|
223
|
+
${e ? l : p`<button
|
|
224
|
+
type="button"
|
|
225
|
+
class="baht-tag-chip-remove"
|
|
226
|
+
aria-label=${r("removeTag", { tag: this.chipLabel(i) })}
|
|
227
|
+
title=${r("removeTag", { tag: this.chipLabel(i) })}
|
|
228
|
+
@click=${() => {
|
|
229
|
+
this.removeTag(i);
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
×
|
|
233
|
+
</button>`}
|
|
234
|
+
</span>`
|
|
235
|
+
)}
|
|
236
|
+
<input
|
|
237
|
+
class="baht-tags-input"
|
|
238
|
+
id=${this.inputId}
|
|
239
|
+
type="text"
|
|
240
|
+
autocomplete="off"
|
|
241
|
+
role="combobox"
|
|
242
|
+
aria-labelledby=${this.field.label ? this.labelId : l}
|
|
243
|
+
aria-expanded=${t ? "true" : "false"}
|
|
244
|
+
aria-controls=${this.listboxId}
|
|
245
|
+
aria-autocomplete="list"
|
|
246
|
+
aria-activedescendant=${t && this.activeIndex >= 0 ? this.optionId(this.activeIndex) : l}
|
|
247
|
+
aria-invalid=${this.invalid}
|
|
248
|
+
aria-describedby=${this.controlDescribedBy}
|
|
249
|
+
placeholder=${this.tags.length === 0 ? this.field.placeholder ?? "" : ""}
|
|
250
|
+
.value=${b(this.inputText)}
|
|
251
|
+
?disabled=${e}
|
|
252
|
+
@input=${(i) => {
|
|
253
|
+
this.onInput(i);
|
|
254
|
+
}}
|
|
255
|
+
@keydown=${(i) => {
|
|
256
|
+
this.onKeydown(i);
|
|
257
|
+
}}
|
|
258
|
+
@paste=${(i) => {
|
|
259
|
+
this.onPaste(i);
|
|
260
|
+
}}
|
|
261
|
+
@blur=${() => {
|
|
262
|
+
this.onBlur();
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
${this.hint ? p`<div class="baht-tags-hint" id=${this.hintId} role="status">${this.hint}</div>` : l}
|
|
267
|
+
<div
|
|
268
|
+
class="baht-listbox baht-tags-list"
|
|
269
|
+
id=${this.listboxId}
|
|
270
|
+
role="listbox"
|
|
271
|
+
aria-label=${this.field.label ?? ""}
|
|
272
|
+
?hidden=${!t}
|
|
273
|
+
>
|
|
274
|
+
${this.suggestions.map(
|
|
275
|
+
// eslint-disable-next-line lit-a11y/click-events-have-key-events -- keyboard lives on the combobox input (aria-activedescendant pattern)
|
|
276
|
+
(i, s) => p`<div
|
|
277
|
+
class="baht-listbox-option${s === this.activeIndex ? " baht-listbox-option--active" : ""}"
|
|
278
|
+
id=${this.optionId(s)}
|
|
279
|
+
role="option"
|
|
280
|
+
aria-selected=${s === this.activeIndex ? "true" : "false"}
|
|
281
|
+
data-key=${i.key}
|
|
282
|
+
@mousedown=${(o) => {
|
|
283
|
+
o.preventDefault();
|
|
284
|
+
}}
|
|
285
|
+
@mousemove=${() => {
|
|
286
|
+
this.highlight(s);
|
|
287
|
+
}}
|
|
288
|
+
@click=${() => {
|
|
289
|
+
this.pick(i);
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
${i.label}
|
|
293
|
+
</div>`
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
</div>`;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
c([
|
|
300
|
+
d()
|
|
301
|
+
], h.prototype, "inputText", 2);
|
|
302
|
+
c([
|
|
303
|
+
d()
|
|
304
|
+
], h.prototype, "open", 2);
|
|
305
|
+
c([
|
|
306
|
+
d()
|
|
307
|
+
], h.prototype, "activeIndex", 2);
|
|
308
|
+
c([
|
|
309
|
+
d()
|
|
310
|
+
], h.prototype, "hint", 2);
|
|
311
|
+
c([
|
|
312
|
+
f("input")
|
|
313
|
+
], h.prototype, "inputEl", 2);
|
|
314
|
+
h = c([
|
|
315
|
+
m("baht-tags")
|
|
316
|
+
], h);
|
|
317
|
+
export {
|
|
318
|
+
h as BahtTags
|
|
319
|
+
};
|