@enigmax/primitives 0.1.2 → 0.3.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/dist/chunk-OKNWKBQI.js +617 -0
- package/dist/index.d.ts +143 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +53 -3
- package/dist/react/index.js +81 -3
- package/package.json +14 -4
- package/recipes/input.css +32 -0
- package/recipes/input.css.tsx +28 -0
- package/recipes/input.tailwind.tsx +44 -0
- package/recipes/marquee.css +22 -0
- package/recipes/marquee.css.tsx +52 -0
- package/recipes/marquee.tailwind.tsx +61 -0
- package/recipes/search.tailwind.tsx +73 -0
- package/registry.json +209 -8
- package/src/core/input.ts +315 -0
- package/src/core/search.ts +225 -0
- package/src/index.ts +2 -0
- package/src/react/index.ts +4 -0
- package/src/react/use-input.ts +70 -0
- package/src/react/use-search.ts +77 -0
- package/dist/chunk-55J5JEWX.js +0 -288
package/registry.json
CHANGED
|
@@ -7,28 +7,72 @@
|
|
|
7
7
|
"name": "marquee",
|
|
8
8
|
"title": "Draggable infinite marquee",
|
|
9
9
|
"description": "Looping row that takes a speed in px/s, measures its own lap, and can be grabbed and thrown without swallowing the clicks on its items.",
|
|
10
|
-
"targets": [
|
|
10
|
+
"targets": [
|
|
11
|
+
"vanilla",
|
|
12
|
+
"astro",
|
|
13
|
+
"react"
|
|
14
|
+
],
|
|
11
15
|
"entry": {
|
|
12
16
|
"vanilla": "@enigmax/primitives",
|
|
13
17
|
"astro": "@enigmax/primitives",
|
|
14
18
|
"react": "@enigmax/primitives/react"
|
|
15
19
|
},
|
|
16
20
|
"exports": {
|
|
17
|
-
"vanilla": [
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
"vanilla": [
|
|
22
|
+
"createMarquee"
|
|
23
|
+
],
|
|
24
|
+
"astro": [
|
|
25
|
+
"createMarquee"
|
|
26
|
+
],
|
|
27
|
+
"react": [
|
|
28
|
+
"useMarquee"
|
|
29
|
+
]
|
|
20
30
|
},
|
|
21
31
|
"files": [
|
|
22
32
|
{
|
|
23
33
|
"path": "src/core/marquee.ts",
|
|
24
34
|
"dest": "marquee.ts",
|
|
25
|
-
"targets": [
|
|
35
|
+
"targets": [
|
|
36
|
+
"vanilla",
|
|
37
|
+
"astro",
|
|
38
|
+
"react"
|
|
39
|
+
]
|
|
26
40
|
},
|
|
27
41
|
{
|
|
28
42
|
"path": "src/react/use-marquee.ts",
|
|
29
43
|
"dest": "use-marquee.ts",
|
|
30
|
-
"targets": [
|
|
31
|
-
|
|
44
|
+
"targets": [
|
|
45
|
+
"react"
|
|
46
|
+
],
|
|
47
|
+
"rewrite": {
|
|
48
|
+
"@/core/marquee": "./marquee"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"path": "recipes/marquee.tailwind.tsx",
|
|
53
|
+
"dest": "Marquee.tsx",
|
|
54
|
+
"targets": [
|
|
55
|
+
"react"
|
|
56
|
+
],
|
|
57
|
+
"style": "tailwind"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"path": "recipes/marquee.css.tsx",
|
|
61
|
+
"dest": "Marquee.tsx",
|
|
62
|
+
"targets": [
|
|
63
|
+
"react"
|
|
64
|
+
],
|
|
65
|
+
"style": "css"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"path": "recipes/marquee.css",
|
|
69
|
+
"dest": "marquee.css",
|
|
70
|
+
"targets": [
|
|
71
|
+
"react",
|
|
72
|
+
"vanilla",
|
|
73
|
+
"astro"
|
|
74
|
+
],
|
|
75
|
+
"style": "css"
|
|
32
76
|
}
|
|
33
77
|
],
|
|
34
78
|
"styles": false,
|
|
@@ -40,7 +84,164 @@
|
|
|
40
84
|
"[data-enigma-marquee-track]",
|
|
41
85
|
"[data-enigma-marquee-copy]"
|
|
42
86
|
],
|
|
43
|
-
"docs": "docs/notes/primitives.md#marquee"
|
|
87
|
+
"docs": "docs/notes/primitives.md#marquee",
|
|
88
|
+
"recipes": [
|
|
89
|
+
"tailwind",
|
|
90
|
+
"css"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"name": "input",
|
|
95
|
+
"title": "Input affordances",
|
|
96
|
+
"description": "In-field buttons with no styles of their own. A password field gets its reveal toggle automatically; the icon, label, side and container are all replaceable, and any other action plugs into the same mechanism.",
|
|
97
|
+
"targets": [
|
|
98
|
+
"vanilla",
|
|
99
|
+
"astro",
|
|
100
|
+
"react"
|
|
101
|
+
],
|
|
102
|
+
"entry": {
|
|
103
|
+
"vanilla": "@enigmax/primitives",
|
|
104
|
+
"astro": "@enigmax/primitives",
|
|
105
|
+
"react": "@enigmax/primitives/react"
|
|
106
|
+
},
|
|
107
|
+
"exports": {
|
|
108
|
+
"vanilla": [
|
|
109
|
+
"createInput"
|
|
110
|
+
],
|
|
111
|
+
"astro": [
|
|
112
|
+
"createInput"
|
|
113
|
+
],
|
|
114
|
+
"react": [
|
|
115
|
+
"useInput"
|
|
116
|
+
]
|
|
117
|
+
},
|
|
118
|
+
"files": [
|
|
119
|
+
{
|
|
120
|
+
"path": "src/core/input.ts",
|
|
121
|
+
"dest": "input.ts",
|
|
122
|
+
"targets": [
|
|
123
|
+
"vanilla",
|
|
124
|
+
"astro",
|
|
125
|
+
"react"
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"path": "src/react/use-input.ts",
|
|
130
|
+
"dest": "use-input.ts",
|
|
131
|
+
"targets": [
|
|
132
|
+
"react"
|
|
133
|
+
],
|
|
134
|
+
"rewrite": {
|
|
135
|
+
"@/core/input": "./input"
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"path": "recipes/input.tailwind.tsx",
|
|
140
|
+
"dest": "Field.tsx",
|
|
141
|
+
"targets": [
|
|
142
|
+
"react"
|
|
143
|
+
],
|
|
144
|
+
"style": "tailwind"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"path": "recipes/input.css.tsx",
|
|
148
|
+
"dest": "Field.tsx",
|
|
149
|
+
"targets": [
|
|
150
|
+
"react"
|
|
151
|
+
],
|
|
152
|
+
"style": "css"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"path": "recipes/input.css",
|
|
156
|
+
"dest": "input.css",
|
|
157
|
+
"targets": [
|
|
158
|
+
"react",
|
|
159
|
+
"vanilla",
|
|
160
|
+
"astro"
|
|
161
|
+
],
|
|
162
|
+
"style": "css"
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
"styles": false,
|
|
166
|
+
"themeHooks": [
|
|
167
|
+
"[data-enigma-input]",
|
|
168
|
+
"[data-enigma-input][data-enigma-input-reveal=on]",
|
|
169
|
+
"[data-enigma-input-actions]",
|
|
170
|
+
"[data-enigma-input-actions][data-position=start]",
|
|
171
|
+
"[data-enigma-input-action]",
|
|
172
|
+
"[data-enigma-input-action=reveal]"
|
|
173
|
+
],
|
|
174
|
+
"docs": "docs/notes/primitives.md#input",
|
|
175
|
+
"recipes": [
|
|
176
|
+
"tailwind",
|
|
177
|
+
"css"
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "search",
|
|
182
|
+
"title": "Search as you type",
|
|
183
|
+
"description": "Debouncing, ranking, cancellation and the field wiring, with the matching pluggable. Pass Fuse.js for fuzzy search, your own function to replace the engine, or neither for the built-in accent-insensitive substring matcher.",
|
|
184
|
+
"targets": [
|
|
185
|
+
"vanilla",
|
|
186
|
+
"astro",
|
|
187
|
+
"react"
|
|
188
|
+
],
|
|
189
|
+
"entry": {
|
|
190
|
+
"vanilla": "@enigmax/primitives",
|
|
191
|
+
"astro": "@enigmax/primitives",
|
|
192
|
+
"react": "@enigmax/primitives/react"
|
|
193
|
+
},
|
|
194
|
+
"exports": {
|
|
195
|
+
"vanilla": [
|
|
196
|
+
"createSearch"
|
|
197
|
+
],
|
|
198
|
+
"astro": [
|
|
199
|
+
"createSearch"
|
|
200
|
+
],
|
|
201
|
+
"react": [
|
|
202
|
+
"useSearch"
|
|
203
|
+
]
|
|
204
|
+
},
|
|
205
|
+
"files": [
|
|
206
|
+
{
|
|
207
|
+
"path": "src/core/search.ts",
|
|
208
|
+
"dest": "search.ts",
|
|
209
|
+
"targets": [
|
|
210
|
+
"vanilla",
|
|
211
|
+
"astro",
|
|
212
|
+
"react"
|
|
213
|
+
]
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"path": "src/react/use-search.ts",
|
|
217
|
+
"dest": "use-search.ts",
|
|
218
|
+
"targets": [
|
|
219
|
+
"react"
|
|
220
|
+
],
|
|
221
|
+
"rewrite": {
|
|
222
|
+
"@/core/search": "./search"
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"path": "recipes/search.tailwind.tsx",
|
|
227
|
+
"dest": "Search.tsx",
|
|
228
|
+
"targets": [
|
|
229
|
+
"react"
|
|
230
|
+
],
|
|
231
|
+
"style": "tailwind"
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
"styles": false,
|
|
235
|
+
"themeHooks": [
|
|
236
|
+
"[data-enigma-search]"
|
|
237
|
+
],
|
|
238
|
+
"optionalDependencies": {
|
|
239
|
+
"fuse.js": "Fuzzy matching. Omit it and a built-in substring matcher is used."
|
|
240
|
+
},
|
|
241
|
+
"docs": "docs/notes/primitives.md#search",
|
|
242
|
+
"recipes": [
|
|
243
|
+
"tailwind"
|
|
244
|
+
]
|
|
44
245
|
}
|
|
45
246
|
]
|
|
46
247
|
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input affordances: the buttons that live inside a field, with no styling of their own.
|
|
3
|
+
*
|
|
4
|
+
* A password field gets its reveal toggle automatically, because a field the visitor
|
|
5
|
+
* cannot read back is the single most common cause of a failed sign-in. Everything about
|
|
6
|
+
* it is replaceable - the icon, the label, the position, the container, whether it exists
|
|
7
|
+
* at all - and the same mechanism takes any other action you want in there.
|
|
8
|
+
*
|
|
9
|
+
* The details below are the ones the hand-rolled version gets wrong; each is commented
|
|
10
|
+
* with the failure it prevents.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What an action renders. A string is parsed as HTML, so an inline SVG works. */
|
|
14
|
+
export type InputIcon = string | Node;
|
|
15
|
+
|
|
16
|
+
export interface InputActionState {
|
|
17
|
+
/** The field the action belongs to. */
|
|
18
|
+
input: HTMLInputElement;
|
|
19
|
+
/** True while the password is readable. Always false for a non-password field. */
|
|
20
|
+
revealed: boolean;
|
|
21
|
+
/** The field cannot be acted on: disabled or read-only. */
|
|
22
|
+
locked: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InputAction {
|
|
26
|
+
/** Stable id. Used for `data-action` and to replace a built-in (e.g. "reveal"). */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Accessible name. Becomes both `aria-label` and `title`. */
|
|
29
|
+
label: string | ((state: InputActionState) => string);
|
|
30
|
+
icon: InputIcon | ((state: InputActionState) => InputIcon);
|
|
31
|
+
/** Runs on click and on Enter/Space, because it is a real button. */
|
|
32
|
+
onSelect: (state: InputActionState) => void;
|
|
33
|
+
/** Renders `aria-pressed`. Omit for actions that are not a toggle. */
|
|
34
|
+
pressed?: (state: InputActionState) => boolean;
|
|
35
|
+
/** Hide the action without removing it, e.g. a clear button on an empty field. */
|
|
36
|
+
visible?: (state: InputActionState) => boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InputOptions {
|
|
40
|
+
/**
|
|
41
|
+
* The password reveal. `false` removes it. An object overrides parts of it - the
|
|
42
|
+
* icons, the labels - without giving up the caret and focus handling.
|
|
43
|
+
*/
|
|
44
|
+
reveal?: boolean | {
|
|
45
|
+
/** Shown while the password is hidden; selecting it reveals. */
|
|
46
|
+
showIcon?: InputIcon;
|
|
47
|
+
/** Shown while the password is readable. */
|
|
48
|
+
hideIcon?: InputIcon;
|
|
49
|
+
showLabel?: string;
|
|
50
|
+
hideLabel?: string;
|
|
51
|
+
};
|
|
52
|
+
/** Extra actions, or a replacement for a built-in when `name` matches. */
|
|
53
|
+
actions?: InputAction[];
|
|
54
|
+
/** Which side the actions mount on. Position them yourself with CSS. */
|
|
55
|
+
position?: "start" | "end";
|
|
56
|
+
/**
|
|
57
|
+
* Mount the actions here instead of in a container created next to the input.
|
|
58
|
+
* Use it when your markup already has a slot for them.
|
|
59
|
+
*/
|
|
60
|
+
container?: HTMLElement;
|
|
61
|
+
/** Called whenever the password's visibility changes. */
|
|
62
|
+
onRevealChange?: (revealed: boolean) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface InputInstance {
|
|
66
|
+
readonly revealed: boolean;
|
|
67
|
+
/** Show or hide the password. Toggles when the argument is omitted. */
|
|
68
|
+
reveal(next?: boolean): void;
|
|
69
|
+
/** Re-read the field and re-render the actions. Call after changing it yourself. */
|
|
70
|
+
refresh(): void;
|
|
71
|
+
update(options: Partial<InputOptions>): void;
|
|
72
|
+
destroy(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Neutral 1em glyphs that inherit `color`. Replace them with anything. */
|
|
76
|
+
const EYE = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
77
|
+
const EYE_OFF = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9"/><path d="m2 2 20 20"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>';
|
|
78
|
+
|
|
79
|
+
function resolve<T>(value: T | ((state: InputActionState) => T), state: InputActionState): T {
|
|
80
|
+
return typeof value === "function" ? (value as (state: InputActionState) => T)(state) : value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function paint(target: HTMLElement, icon: InputIcon): void {
|
|
84
|
+
if (typeof icon === "string") target.innerHTML = icon;
|
|
85
|
+
else {
|
|
86
|
+
target.replaceChildren(icon);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wire a field's in-field actions.
|
|
92
|
+
*
|
|
93
|
+
* @param input The field itself. A `type="password"` field gets the reveal for free.
|
|
94
|
+
*/
|
|
95
|
+
export function createInput(input: HTMLInputElement, options: InputOptions = {}): InputInstance {
|
|
96
|
+
let opts: InputOptions = { ...options };
|
|
97
|
+
let revealed = false;
|
|
98
|
+
let destroyed = false;
|
|
99
|
+
let restoreTimer: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
|
|
101
|
+
const owned = !opts.container;
|
|
102
|
+
const container = opts.container ?? document.createElement("span");
|
|
103
|
+
const buttons = new Map<string, HTMLButtonElement>();
|
|
104
|
+
|
|
105
|
+
function state(): InputActionState {
|
|
106
|
+
return { input, revealed, locked: input.disabled || input.readOnly };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function revealConfig() {
|
|
110
|
+
return typeof opts.reveal === "object" ? opts.reveal : {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The built-in, presented as an ordinary action so it can be replaced by name. */
|
|
114
|
+
function revealAction(): InputAction {
|
|
115
|
+
const config = revealConfig();
|
|
116
|
+
return {
|
|
117
|
+
name: "reveal",
|
|
118
|
+
label: (current) => (current.revealed ? config.hideLabel ?? "Hide password" : config.showLabel ?? "Show password"),
|
|
119
|
+
icon: (current) => (current.revealed ? config.hideIcon ?? EYE_OFF : config.showIcon ?? EYE),
|
|
120
|
+
pressed: (current) => current.revealed,
|
|
121
|
+
onSelect: () => setRevealed(!revealed)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A password field is one whose type is password OR one already revealed by us. */
|
|
126
|
+
function isPasswordField(): boolean {
|
|
127
|
+
return input.type === "password" || (revealed && input.dataset.enigmaInputReveal === "on");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function activeActions(): InputAction[] {
|
|
131
|
+
const custom = opts.actions ?? [];
|
|
132
|
+
const wantsReveal = opts.reveal !== false && isPasswordField();
|
|
133
|
+
const builtIn = wantsReveal && !custom.some((action) => action.name === "reveal") ? [revealAction()] : [];
|
|
134
|
+
return [...builtIn, ...custom];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setRevealed(next: boolean): void {
|
|
138
|
+
if (!isPasswordField() && next) return;
|
|
139
|
+
// Captured BEFORE the switch. Assigning `type` inside a click handler resets the
|
|
140
|
+
// caret to 0 in Chromium - reproduced in twenty lines with no library, and it does
|
|
141
|
+
// NOT happen when the same assignment runs outside an event - so anything read
|
|
142
|
+
// afterwards is already the clobbered value.
|
|
143
|
+
const selection = readSelection();
|
|
144
|
+
|
|
145
|
+
revealed = next;
|
|
146
|
+
input.type = next ? "text" : "password";
|
|
147
|
+
input.dataset.enigmaInputReveal = next ? "on" : "off";
|
|
148
|
+
|
|
149
|
+
render();
|
|
150
|
+
|
|
151
|
+
// After the render, because rewriting the buttons moves it again.
|
|
152
|
+
if (selection) {
|
|
153
|
+
applySelection(selection);
|
|
154
|
+
// And once more on the next MACROTASK. Chromium clobbers the caret exactly
|
|
155
|
+
// there - not synchronously and not in a microtask, both of which still read
|
|
156
|
+
// the right value - so a restore that only runs inline silently loses. The
|
|
157
|
+
// field is re-checked for focus first, so a visitor who clicked elsewhere in
|
|
158
|
+
// the meantime is not dragged back.
|
|
159
|
+
if (restoreTimer !== null) clearTimeout(restoreTimer);
|
|
160
|
+
restoreTimer = setTimeout(() => {
|
|
161
|
+
restoreTimer = null;
|
|
162
|
+
if (!destroyed && document.activeElement === input) applySelection(selection);
|
|
163
|
+
}, 0);
|
|
164
|
+
}
|
|
165
|
+
opts.onRevealChange?.(next);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Read the field's caret, if it has one and owns the focus. */
|
|
169
|
+
function readSelection(): [number, number] | null {
|
|
170
|
+
if (document.activeElement !== input) return null;
|
|
171
|
+
try {
|
|
172
|
+
const { selectionStart, selectionEnd } = input;
|
|
173
|
+
return selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
|
|
174
|
+
} catch {
|
|
175
|
+
return null; // selection is not supported on this input type
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function applySelection(selection: [number, number]): void {
|
|
180
|
+
try { input.setSelectionRange(selection[0], selection[1]); } catch { /* unsupported */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function render(): void {
|
|
184
|
+
if (destroyed) return;
|
|
185
|
+
// Rewriting the buttons resets the caret of a focused field - measured, and it
|
|
186
|
+
// happens asynchronously too, because the MutationObserver below re-renders after
|
|
187
|
+
// the type switch. Preserving it HERE covers every trigger: the observer, an
|
|
188
|
+
// update(), a caller's refresh(). Doing it only in setRevealed left the reveal
|
|
189
|
+
// sending the cursor to position 0 one task later.
|
|
190
|
+
const selection = readSelection();
|
|
191
|
+
const current = state();
|
|
192
|
+
const actions = activeActions();
|
|
193
|
+
const seen = new Set<string>();
|
|
194
|
+
|
|
195
|
+
for (const action of actions) {
|
|
196
|
+
seen.add(action.name);
|
|
197
|
+
const visible = action.visible ? action.visible(current) : true;
|
|
198
|
+
let button = buttons.get(action.name);
|
|
199
|
+
|
|
200
|
+
if (!button) {
|
|
201
|
+
button = document.createElement("button");
|
|
202
|
+
// Without this a button inside a form SUBMITS it, so revealing a password
|
|
203
|
+
// posts the half-filled form. It is the single most common bug here.
|
|
204
|
+
button.type = "button";
|
|
205
|
+
button.dataset.enigmaInputAction = action.name;
|
|
206
|
+
buttons.set(action.name, button);
|
|
207
|
+
container.append(button);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!visible) {
|
|
211
|
+
// Removed rather than `hidden`: the hidden attribute works through a UA
|
|
212
|
+
// `display: none`, and ANY author rule that sets `display` on the button
|
|
213
|
+
// beats it - which a theme styling these buttons always does.
|
|
214
|
+
button.remove();
|
|
215
|
+
buttons.delete(action.name);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!button.isConnected) container.append(button);
|
|
219
|
+
|
|
220
|
+
const label = resolve(action.label, current);
|
|
221
|
+
button.setAttribute("aria-label", label);
|
|
222
|
+
button.title = label;
|
|
223
|
+
button.disabled = current.locked;
|
|
224
|
+
if (action.pressed) button.setAttribute("aria-pressed", String(action.pressed(current)));
|
|
225
|
+
else button.removeAttribute("aria-pressed");
|
|
226
|
+
paint(button, resolve(action.icon, current));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const [name, button] of [...buttons]) {
|
|
230
|
+
if (seen.has(name)) continue;
|
|
231
|
+
button.remove();
|
|
232
|
+
buttons.delete(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
container.hidden = buttons.size === 0;
|
|
236
|
+
|
|
237
|
+
if (selection) {
|
|
238
|
+
try { input.setSelectionRange(selection[0], selection[1]); } catch { /* unsupported */ }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A press on an action must not pull focus out of the field: the visitor is mid-word
|
|
244
|
+
* and expects to keep typing. Preventing the default on mousedown keeps focus where
|
|
245
|
+
* it is, which also means the caret survives on its own. Keyboard users still reach
|
|
246
|
+
* the button with Tab and activate it with Space, and focus correctly stays there.
|
|
247
|
+
*/
|
|
248
|
+
function onMouseDown(event: MouseEvent): void {
|
|
249
|
+
if (!(event.target as HTMLElement | null)?.closest("[data-enigma-input-action]")) return;
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function onClick(event: MouseEvent): void {
|
|
254
|
+
const target = (event.target as HTMLElement | null)?.closest<HTMLElement>("[data-enigma-input-action]");
|
|
255
|
+
if (!target) return;
|
|
256
|
+
const action = activeActions().find((candidate) => candidate.name === target.dataset.enigmaInputAction);
|
|
257
|
+
if (!action) return;
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
action.onSelect(state());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The field's own type can change from outside; the reveal has to follow. */
|
|
263
|
+
const observer = typeof MutationObserver === "function"
|
|
264
|
+
? new MutationObserver(() => {
|
|
265
|
+
if (!isPasswordField() && revealed) revealed = false;
|
|
266
|
+
render();
|
|
267
|
+
})
|
|
268
|
+
: null;
|
|
269
|
+
|
|
270
|
+
function mount(): void {
|
|
271
|
+
input.dataset.enigmaInput = "";
|
|
272
|
+
container.dataset.enigmaInputActions = "";
|
|
273
|
+
container.dataset.position = opts.position ?? "end";
|
|
274
|
+
if (owned && !container.isConnected) {
|
|
275
|
+
if ((opts.position ?? "end") === "start") input.before(container);
|
|
276
|
+
else input.after(container);
|
|
277
|
+
}
|
|
278
|
+
container.addEventListener("mousedown", onMouseDown);
|
|
279
|
+
container.addEventListener("click", onClick);
|
|
280
|
+
observer?.observe(input, { attributes: true, attributeFilter: ["type", "disabled", "readonly"] });
|
|
281
|
+
render();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
mount();
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
get revealed() { return revealed; },
|
|
288
|
+
reveal(next?: boolean) { setRevealed(next ?? !revealed); },
|
|
289
|
+
refresh: render,
|
|
290
|
+
update(next: Partial<InputOptions>) {
|
|
291
|
+
const moved = next.position !== undefined && next.position !== opts.position;
|
|
292
|
+
opts = { ...opts, ...next };
|
|
293
|
+
if (moved && owned) {
|
|
294
|
+
container.dataset.position = opts.position ?? "end";
|
|
295
|
+
if (opts.position === "start") input.before(container);
|
|
296
|
+
else input.after(container);
|
|
297
|
+
}
|
|
298
|
+
render();
|
|
299
|
+
},
|
|
300
|
+
destroy() {
|
|
301
|
+
destroyed = true;
|
|
302
|
+
if (restoreTimer !== null) clearTimeout(restoreTimer);
|
|
303
|
+
observer?.disconnect();
|
|
304
|
+
container.removeEventListener("mousedown", onMouseDown);
|
|
305
|
+
container.removeEventListener("click", onClick);
|
|
306
|
+
for (const button of buttons.values()) button.remove();
|
|
307
|
+
buttons.clear();
|
|
308
|
+
if (owned) container.remove();
|
|
309
|
+
delete input.dataset.enigmaInput;
|
|
310
|
+
delete input.dataset.enigmaInputReveal;
|
|
311
|
+
// A field left as text would leak the password on the next render.
|
|
312
|
+
if (revealed) input.type = "password";
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|