@neumorphism-ui/elements 0.0.0-stage → 1.0.0-next.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/LICENSE +21 -0
- package/README.md +63 -2
- package/cdn/neu.min.js +205 -0
- package/custom-elements.json +1892 -0
- package/dist/all.d.ts +12 -0
- package/dist/all.js +12 -0
- package/dist/avatar.d.ts +3 -0
- package/dist/avatar.js +4 -0
- package/dist/badge.d.ts +3 -0
- package/dist/badge.js +4 -0
- package/dist/button-group.d.ts +3 -0
- package/dist/button-group.js +4 -0
- package/dist/button.d.ts +3 -0
- package/dist/button.js +4 -0
- package/dist/components/avatar/avatar.d.ts +38 -0
- package/dist/components/avatar/avatar.def.d.ts +24 -0
- package/dist/components/avatar/avatar.def.js +33 -0
- package/dist/components/avatar/avatar.js +58 -0
- package/dist/components/avatar/avatar.styles.d.ts +1 -0
- package/dist/components/avatar/avatar.styles.js +13 -0
- package/dist/components/badge/badge.d.ts +17 -0
- package/dist/components/badge/badge.def.d.ts +12 -0
- package/dist/components/badge/badge.def.js +29 -0
- package/dist/components/badge/badge.js +5 -0
- package/dist/components/badge/badge.styles.d.ts +1 -0
- package/dist/components/badge/badge.styles.js +10 -0
- package/dist/components/button/button.d.ts +25 -0
- package/dist/components/button/button.def.d.ts +28 -0
- package/dist/components/button/button.def.js +45 -0
- package/dist/components/button/button.js +87 -0
- package/dist/components/button/button.styles.d.ts +2 -0
- package/dist/components/button/button.styles.js +43 -0
- package/dist/components/button-group/button-group.d.ts +19 -0
- package/dist/components/button-group/button-group.def.d.ts +12 -0
- package/dist/components/button-group/button-group.def.js +25 -0
- package/dist/components/button-group/button-group.js +13 -0
- package/dist/components/button-group/button-group.styles.d.ts +7 -0
- package/dist/components/button-group/button-group.styles.js +19 -0
- package/dist/components/divider/divider.d.ts +14 -0
- package/dist/components/divider/divider.def.d.ts +8 -0
- package/dist/components/divider/divider.def.js +24 -0
- package/dist/components/divider/divider.js +13 -0
- package/dist/components/divider/divider.styles.d.ts +1 -0
- package/dist/components/divider/divider.styles.js +7 -0
- package/dist/components/icon/icon-drawer.d.ts +12 -0
- package/dist/components/icon/icon-drawer.js +38 -0
- package/dist/components/icon/icon-loader.d.ts +5 -0
- package/dist/components/icon/icon-loader.js +102 -0
- package/dist/components/icon/icon-registry.d.ts +11 -0
- package/dist/components/icon/icon-registry.js +26 -0
- package/dist/components/icon/icon.d.ts +25 -0
- package/dist/components/icon/icon.def.d.ts +15 -0
- package/dist/components/icon/icon.def.js +24 -0
- package/dist/components/icon/icon.js +23 -0
- package/dist/components/icon/icon.styles.d.ts +1 -0
- package/dist/components/icon/icon.styles.js +7 -0
- package/dist/components/icon/system-icons.d.ts +2 -0
- package/dist/components/icon/system-icons.js +17 -0
- package/dist/components/icon-button/icon-button.d.ts +51 -0
- package/dist/components/icon-button/icon-button.def.d.ts +40 -0
- package/dist/components/icon-button/icon-button.def.js +44 -0
- package/dist/components/icon-button/icon-button.js +21 -0
- package/dist/components/icon-button/icon-button.styles.d.ts +1 -0
- package/dist/components/icon-button/icon-button.styles.js +12 -0
- package/dist/components/spinner/spinner.d.ts +19 -0
- package/dist/components/spinner/spinner.def.d.ts +12 -0
- package/dist/components/spinner/spinner.def.js +25 -0
- package/dist/components/spinner/spinner.js +17 -0
- package/dist/components/spinner/spinner.styles.d.ts +1 -0
- package/dist/components/spinner/spinner.styles.js +13 -0
- package/dist/components/surface/surface.d.ts +33 -0
- package/dist/components/surface/surface.def.d.ts +26 -0
- package/dist/components/surface/surface.def.js +27 -0
- package/dist/components/surface/surface.js +5 -0
- package/dist/components/surface/surface.styles.d.ts +1 -0
- package/dist/components/surface/surface.styles.js +17 -0
- package/dist/components/switch/switch.d.ts +60 -0
- package/dist/components/switch/switch.def.d.ts +28 -0
- package/dist/components/switch/switch.def.js +33 -0
- package/dist/components/switch/switch.js +125 -0
- package/dist/components/switch/switch.styles.d.ts +1 -0
- package/dist/components/switch/switch.styles.js +29 -0
- package/dist/components/text-field/implicit-submit.d.ts +8 -0
- package/dist/components/text-field/implicit-submit.js +42 -0
- package/dist/components/text-field/text-field.d.ts +116 -0
- package/dist/components/text-field/text-field.def.d.ts +143 -0
- package/dist/components/text-field/text-field.def.js +63 -0
- package/dist/components/text-field/text-field.js +207 -0
- package/dist/components/text-field/text-field.styles.d.ts +1 -0
- package/dist/components/text-field/text-field.styles.js +39 -0
- package/dist/core/define.d.ts +33 -0
- package/dist/core/define.js +15 -0
- package/dist/core/dom.d.ts +21 -0
- package/dist/core/dom.js +51 -0
- package/dist/core/form-control.d.ts +35 -0
- package/dist/core/form-control.js +128 -0
- package/dist/core/html.d.ts +13 -0
- package/dist/core/html.js +35 -0
- package/dist/core/neu-element.d.ts +87 -0
- package/dist/core/neu-element.js +285 -0
- package/dist/core/props.d.ts +44 -0
- package/dist/core/props.js +52 -0
- package/dist/core/styles.d.ts +8 -0
- package/dist/core/styles.js +40 -0
- package/dist/core/validation.d.ts +4 -0
- package/dist/core/validation.js +26 -0
- package/dist/divider.d.ts +3 -0
- package/dist/divider.js +4 -0
- package/dist/icon-button.d.ts +3 -0
- package/dist/icon-button.js +4 -0
- package/dist/icon.d.ts +3 -0
- package/dist/icon.js +4 -0
- package/dist/icons.d.ts +2 -0
- package/dist/icons.js +1 -0
- package/dist/spinner.d.ts +3 -0
- package/dist/spinner.js +4 -0
- package/dist/ssr.d.ts +11 -0
- package/dist/ssr.js +41 -0
- package/dist/surface.d.ts +3 -0
- package/dist/surface.js +4 -0
- package/dist/switch.d.ts +3 -0
- package/dist/switch.js +4 -0
- package/dist/text-field.d.ts +3 -0
- package/dist/text-field.js +4 -0
- package/package.json +104 -3
package/dist/core/dom.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Writes text only when it changed, so hydrated nodes are not replaced needlessly. */
|
|
2
|
+
export function setText(el, text) {
|
|
3
|
+
if (el && el.textContent !== text)
|
|
4
|
+
el.textContent = text;
|
|
5
|
+
}
|
|
6
|
+
/** Sets the attribute when value is non-empty and removes it otherwise. */
|
|
7
|
+
export function setOptionalAttribute(el, name, value) {
|
|
8
|
+
if (!el)
|
|
9
|
+
return;
|
|
10
|
+
if (value === '')
|
|
11
|
+
el.removeAttribute(name);
|
|
12
|
+
else if (el.getAttribute(name) !== value)
|
|
13
|
+
el.setAttribute(name, value);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* True while `el` is the focused element of its tree. It stays true when only the window or frame
|
|
17
|
+
* loses focus, which also fires blur on the element; it is false once focus moved elsewhere.
|
|
18
|
+
*/
|
|
19
|
+
export function isActiveInRoot(el) {
|
|
20
|
+
return el.getRootNode().activeElement === el;
|
|
21
|
+
}
|
|
22
|
+
/** Dispatches a bubbling, composed event from a host (spec §6.4). */
|
|
23
|
+
export function dispatch(target, type) {
|
|
24
|
+
target.dispatchEvent(new Event(type, { bubbles: true, composed: true }));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Runs `run` once `event` has finished dispatching — after every listener, including those on
|
|
28
|
+
* window — or on the next task if propagation was stopped before window. Runs exactly once.
|
|
29
|
+
*
|
|
30
|
+
* A one-shot bubble listener on window catches this same event object, so `run` lands within the
|
|
31
|
+
* dispatch, after the listeners on the way up, and sees their preventDefault(). Not a microtask:
|
|
32
|
+
* for a trusted event the microtask queue drains between listeners. An event that never reaches
|
|
33
|
+
* window (stopPropagation, bubbles: false, a disconnected target) is finished by the timeout.
|
|
34
|
+
*/
|
|
35
|
+
export function afterDispatch(event, run) {
|
|
36
|
+
let done = false;
|
|
37
|
+
const finish = () => {
|
|
38
|
+
if (done)
|
|
39
|
+
return;
|
|
40
|
+
done = true;
|
|
41
|
+
window.removeEventListener(event.type, onWindow);
|
|
42
|
+
clearTimeout(fallback);
|
|
43
|
+
run();
|
|
44
|
+
};
|
|
45
|
+
const onWindow = (other) => {
|
|
46
|
+
if (other === event)
|
|
47
|
+
finish();
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener(event.type, onWindow);
|
|
50
|
+
const fallback = setTimeout(finish, 0);
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { NeuElement } from './neu-element.js';
|
|
2
|
+
/** Records that the user is trying to submit `form` in this task, so validation errors may show. */
|
|
3
|
+
export declare function markSubmitAttempt(form: HTMLFormElement): void;
|
|
4
|
+
/**
|
|
5
|
+
* A static brand on button-like classes whose type="submit" submits their form (NeuButton), so
|
|
6
|
+
* subclasses count under any tag. Symbol.for: two copies of the library on one page agree.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SUBMITS: unique symbol;
|
|
9
|
+
/** A button that submits its form when activated: native submit buttons and branded ones with type=submit. */
|
|
10
|
+
export declare function isSubmitButton(el: Element): boolean;
|
|
11
|
+
/** Base for every control that has a value (spec §6.6). */
|
|
12
|
+
export declare abstract class FormControlElement extends NeuElement {
|
|
13
|
+
static formAssociated: boolean;
|
|
14
|
+
private interacted;
|
|
15
|
+
constructor();
|
|
16
|
+
get form(): HTMLFormElement | null;
|
|
17
|
+
get validity(): ValidityState;
|
|
18
|
+
get validationMessage(): string;
|
|
19
|
+
get willValidate(): boolean;
|
|
20
|
+
checkValidity(): boolean;
|
|
21
|
+
reportValidity(): boolean;
|
|
22
|
+
formResetCallback(): void;
|
|
23
|
+
formStateRestoreCallback(state: string | File | FormData | null): void;
|
|
24
|
+
protected abstract formValue(): string | null;
|
|
25
|
+
/** What the browser saves to restore the control after navigation; null saves nothing. */
|
|
26
|
+
protected formState(): string | null;
|
|
27
|
+
protected abstract validate(): void;
|
|
28
|
+
protected abstract resetToDefault(): void;
|
|
29
|
+
protected abstract restoreState(state: string): void;
|
|
30
|
+
/** Pushes the current value and validity to the form. Call after every value change. */
|
|
31
|
+
protected commit(): void;
|
|
32
|
+
/** The user committed an edit, so validation errors may now be shown. */
|
|
33
|
+
protected markInteracted(): void;
|
|
34
|
+
private syncUserInvalid;
|
|
35
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { NeuElement } from './neu-element.js';
|
|
2
|
+
/** Forms the user is trying to submit during the current task (click on a submitter, or Enter). */
|
|
3
|
+
const submitAttempts = new WeakSet();
|
|
4
|
+
let watching = false;
|
|
5
|
+
/** Records that the user is trying to submit `form` in this task, so validation errors may show. */
|
|
6
|
+
export function markSubmitAttempt(form) {
|
|
7
|
+
submitAttempts.add(form);
|
|
8
|
+
// Validation runs in the same task as the click/keydown/submit; forget the attempt afterwards.
|
|
9
|
+
setTimeout(() => submitAttempts.delete(form), 0);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A static brand on button-like classes whose type="submit" submits their form (NeuButton), so
|
|
13
|
+
* subclasses count under any tag. Symbol.for: two copies of the library on one page agree.
|
|
14
|
+
*/
|
|
15
|
+
export const SUBMITS = Symbol.for('neu-ui.submits');
|
|
16
|
+
/** A button that submits its form when activated: native submit buttons and branded ones with type=submit. */
|
|
17
|
+
export function isSubmitButton(el) {
|
|
18
|
+
if (el instanceof HTMLButtonElement)
|
|
19
|
+
return el.type === 'submit';
|
|
20
|
+
if (el instanceof HTMLInputElement)
|
|
21
|
+
return el.type === 'submit' || el.type === 'image';
|
|
22
|
+
return (el.constructor[SUBMITS] === true &&
|
|
23
|
+
el.type === 'submit');
|
|
24
|
+
}
|
|
25
|
+
/** The form owner of a control (its `form` property), which also covers form="id" association. */
|
|
26
|
+
function formOf(node) {
|
|
27
|
+
const form = node.form;
|
|
28
|
+
return form instanceof HTMLFormElement ? form : null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* One capture listener pair on document sees every submit attempt: submitters outside their form
|
|
32
|
+
* (form="id") and forms inside open shadow roots included. The composed path is walked rather than
|
|
33
|
+
* the retargeted event.target, which would hide a control inside a shadow root from the document.
|
|
34
|
+
*/
|
|
35
|
+
function watchSubmitAttempts() {
|
|
36
|
+
if (watching || typeof document === 'undefined')
|
|
37
|
+
return;
|
|
38
|
+
watching = true;
|
|
39
|
+
document.addEventListener('click', (event) => {
|
|
40
|
+
for (const node of event.composedPath()) {
|
|
41
|
+
if (!(node instanceof Element) || !isSubmitButton(node))
|
|
42
|
+
continue;
|
|
43
|
+
const form = formOf(node);
|
|
44
|
+
if (form)
|
|
45
|
+
markSubmitAttempt(form);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
}, { capture: true });
|
|
49
|
+
document.addEventListener('keydown', (event) => {
|
|
50
|
+
if (event.key !== 'Enter')
|
|
51
|
+
return;
|
|
52
|
+
for (const node of event.composedPath()) {
|
|
53
|
+
// Not document or window: their named properties make `.form` a <form id|name="form">.
|
|
54
|
+
if (!(node instanceof Element))
|
|
55
|
+
continue;
|
|
56
|
+
const form = formOf(node);
|
|
57
|
+
if (form) {
|
|
58
|
+
markSubmitAttempt(form);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}, { capture: true });
|
|
63
|
+
}
|
|
64
|
+
/** Base for every control that has a value (spec §6.6). */
|
|
65
|
+
export class FormControlElement extends NeuElement {
|
|
66
|
+
static formAssociated = true;
|
|
67
|
+
interacted = false;
|
|
68
|
+
constructor() {
|
|
69
|
+
super();
|
|
70
|
+
watchSubmitAttempts();
|
|
71
|
+
// A failed submit attempt fires `invalid`; so does form.checkValidity(), which must stay silent.
|
|
72
|
+
this.addEventListener('invalid', () => {
|
|
73
|
+
const form = this.internals.form;
|
|
74
|
+
if (form && submitAttempts.has(form))
|
|
75
|
+
this.markInteracted();
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
get form() {
|
|
79
|
+
return this.internals.form;
|
|
80
|
+
}
|
|
81
|
+
get validity() {
|
|
82
|
+
return this.internals.validity;
|
|
83
|
+
}
|
|
84
|
+
get validationMessage() {
|
|
85
|
+
return this.internals.validationMessage;
|
|
86
|
+
}
|
|
87
|
+
get willValidate() {
|
|
88
|
+
return this.internals.willValidate;
|
|
89
|
+
}
|
|
90
|
+
checkValidity() {
|
|
91
|
+
return this.internals.checkValidity();
|
|
92
|
+
}
|
|
93
|
+
reportValidity() {
|
|
94
|
+
const valid = this.internals.reportValidity();
|
|
95
|
+
if (!valid)
|
|
96
|
+
this.markInteracted();
|
|
97
|
+
return valid;
|
|
98
|
+
}
|
|
99
|
+
formResetCallback() {
|
|
100
|
+
this.interacted = false;
|
|
101
|
+
this.resetToDefault();
|
|
102
|
+
this.commit();
|
|
103
|
+
}
|
|
104
|
+
formStateRestoreCallback(state) {
|
|
105
|
+
if (typeof state !== 'string')
|
|
106
|
+
return;
|
|
107
|
+
this.restoreState(state);
|
|
108
|
+
this.commit();
|
|
109
|
+
}
|
|
110
|
+
/** What the browser saves to restore the control after navigation; null saves nothing. */
|
|
111
|
+
formState() {
|
|
112
|
+
return this.formValue() ?? '';
|
|
113
|
+
}
|
|
114
|
+
/** Pushes the current value and validity to the form. Call after every value change. */
|
|
115
|
+
commit() {
|
|
116
|
+
this.internals.setFormValue(this.formValue(), this.formState());
|
|
117
|
+
this.validate();
|
|
118
|
+
this.syncUserInvalid();
|
|
119
|
+
}
|
|
120
|
+
/** The user committed an edit, so validation errors may now be shown. */
|
|
121
|
+
markInteracted() {
|
|
122
|
+
this.interacted = true;
|
|
123
|
+
this.syncUserInvalid();
|
|
124
|
+
}
|
|
125
|
+
syncUserInvalid() {
|
|
126
|
+
this.toggleState('user-invalid', this.interacted && !this.internals.validity.valid);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare const RAW: unique symbol;
|
|
2
|
+
/** A string that is already safe HTML. Only `html` and `unsafeHTML` create one. */
|
|
3
|
+
export interface RawHtml {
|
|
4
|
+
readonly [RAW]: true;
|
|
5
|
+
readonly value: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function escapeHtml(value: string): string;
|
|
8
|
+
/** Marks markup as trusted. Only for library-owned markup such as built-in SVG icons. */
|
|
9
|
+
export declare function unsafeHTML(value: string): RawHtml;
|
|
10
|
+
export declare function isRawHtml(value: unknown): value is RawHtml;
|
|
11
|
+
/** Tagged template that escapes every interpolated value unless it is RawHtml. */
|
|
12
|
+
export declare function html(strings: TemplateStringsArray, ...values: unknown[]): RawHtml;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const RAW = Symbol('neu.raw');
|
|
2
|
+
const ESCAPES = {
|
|
3
|
+
'&': '&',
|
|
4
|
+
'<': '<',
|
|
5
|
+
'>': '>',
|
|
6
|
+
'"': '"',
|
|
7
|
+
"'": ''',
|
|
8
|
+
};
|
|
9
|
+
export function escapeHtml(value) {
|
|
10
|
+
return value.replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
|
|
11
|
+
}
|
|
12
|
+
/** Marks markup as trusted. Only for library-owned markup such as built-in SVG icons. */
|
|
13
|
+
export function unsafeHTML(value) {
|
|
14
|
+
return { [RAW]: true, value };
|
|
15
|
+
}
|
|
16
|
+
export function isRawHtml(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && RAW in value;
|
|
18
|
+
}
|
|
19
|
+
function render(value) {
|
|
20
|
+
if (value === null || value === undefined || value === false)
|
|
21
|
+
return '';
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.map(render).join('');
|
|
24
|
+
if (isRawHtml(value))
|
|
25
|
+
return value.value;
|
|
26
|
+
return escapeHtml(String(value));
|
|
27
|
+
}
|
|
28
|
+
/** Tagged template that escapes every interpolated value unless it is RawHtml. */
|
|
29
|
+
export function html(strings, ...values) {
|
|
30
|
+
let out = strings[0] ?? '';
|
|
31
|
+
values.forEach((value, index) => {
|
|
32
|
+
out += render(value) + (strings[index + 1] ?? '');
|
|
33
|
+
});
|
|
34
|
+
return unsafeHTML(out);
|
|
35
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ComponentDef } from './define.js';
|
|
2
|
+
import { type PropValue } from './props.js';
|
|
3
|
+
/**
|
|
4
|
+
* HTMLElement does not exist in Node (SSR, React server rendering). Falling back to an empty
|
|
5
|
+
* class lets every module be imported there; nothing is ever constructed on the server.
|
|
6
|
+
*/
|
|
7
|
+
declare const ServerSafeHTMLElement: typeof HTMLElement;
|
|
8
|
+
export declare abstract class NeuElement extends ServerSafeHTMLElement {
|
|
9
|
+
static def: ComponentDef;
|
|
10
|
+
/** Own properties to re-apply after upgrade besides def.props (spec §6.2 step 3). */
|
|
11
|
+
static extraProperties: readonly string[];
|
|
12
|
+
static get observedAttributes(): string[];
|
|
13
|
+
/** Installs accessors for def.props and registers the tag. Safe to call in Node. */
|
|
14
|
+
static define(): void;
|
|
15
|
+
/**
|
|
16
|
+
* True when this class defines `key`, or a class between it and NeuElement wrote its own
|
|
17
|
+
* accessor for it. Accessors that define() installed on a parent do not count: they coerce with
|
|
18
|
+
* the parent's declarations, and this class may declare the prop differently.
|
|
19
|
+
*/
|
|
20
|
+
private static definesAccessor;
|
|
21
|
+
protected readonly internals: ElementInternals;
|
|
22
|
+
protected readonly props: Record<string, PropValue>;
|
|
23
|
+
private root;
|
|
24
|
+
private readonly refs;
|
|
25
|
+
private reflecting;
|
|
26
|
+
private hasConnected;
|
|
27
|
+
private formDisabled;
|
|
28
|
+
/** The author's tabindex (null when none is set), kept current by syncFocusable. */
|
|
29
|
+
private authorTabIndex;
|
|
30
|
+
/** The tabindex syncFocusable last left on the host (null: removed; undefined: never ran). */
|
|
31
|
+
private writtenTabIndex;
|
|
32
|
+
constructor();
|
|
33
|
+
connectedCallback(): void;
|
|
34
|
+
disconnectedCallback(): void;
|
|
35
|
+
attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
|
|
36
|
+
/** Called by the browser for form-associated subclasses inside a disabled fieldset. */
|
|
37
|
+
formDisabledCallback(disabled: boolean): void;
|
|
38
|
+
protected get def(): ComponentDef;
|
|
39
|
+
protected get isDisabled(): boolean;
|
|
40
|
+
/** Element marked data-ref="name" in the shadow root, or null before the root exists. */
|
|
41
|
+
protected ref<T extends Element = HTMLElement>(name: string): T | null;
|
|
42
|
+
protected setProp(key: string, value: PropValue, fromAttribute?: boolean): void;
|
|
43
|
+
protected toggleState(name: string, on: boolean): void;
|
|
44
|
+
/**
|
|
45
|
+
* Focusable: the author's tabindex, or 0. Not focusable: no tabindex at all.
|
|
46
|
+
* A tabindex other than the one this method last left there was set (or removed) by the author
|
|
47
|
+
* since, as a roving-tabindex widget or a framework re-render does; it becomes the author's value.
|
|
48
|
+
*/
|
|
49
|
+
protected syncFocusable(focusable: boolean): void;
|
|
50
|
+
/** For controls whose host is the control: aria-disabled and focusability follow isDisabled. */
|
|
51
|
+
protected syncDisabledState(): void;
|
|
52
|
+
/**
|
|
53
|
+
* Runs on every prop change, connected or not. ElementInternals work (form value, validity) is
|
|
54
|
+
* fine here; leave shadow-DOM and attribute writes to update().
|
|
55
|
+
*/
|
|
56
|
+
protected willUpdate(_changed: ReadonlySet<string>): void;
|
|
57
|
+
/** Patches host state and shadow DOM for the changed props. Must be idempotent. */
|
|
58
|
+
protected update(_changed: ReadonlySet<string>): void;
|
|
59
|
+
/** Runs once, after the shadow root exists (rendered or adopted from SSR). */
|
|
60
|
+
protected rendered(): void;
|
|
61
|
+
protected connected(): void;
|
|
62
|
+
protected disconnected(): void;
|
|
63
|
+
private allKeys;
|
|
64
|
+
/** willUpdate, then update once connected, with the same one-key set. */
|
|
65
|
+
private propChanged;
|
|
66
|
+
private reflect;
|
|
67
|
+
private upgradeProperties;
|
|
68
|
+
/** `loaded`: DOMContentLoaded fired, so the parser is done and nothing is left to wait for. */
|
|
69
|
+
private ensureRoot;
|
|
70
|
+
/**
|
|
71
|
+
* While the document loads, the parser may still be writing this element's declarative shadow
|
|
72
|
+
* root (spec §8.1). Before the root exists, that is only possible while nothing has been parsed
|
|
73
|
+
* inside or after the element; one created by script in the middle of the page renders at once.
|
|
74
|
+
* Once it exists, its contents may still be streaming in: it is unfinished while it has fewer
|
|
75
|
+
* than `refs` data-ref nodes, and adopting it then would run rendered() without them.
|
|
76
|
+
*/
|
|
77
|
+
private parsing;
|
|
78
|
+
/**
|
|
79
|
+
* Without a root, watches the document until a child or a following node shows the parser has
|
|
80
|
+
* moved past the element; with an unfinished root, watches that root until it is complete. Then
|
|
81
|
+
* ensureRoot() decides again. DOMContentLoaded is the fallback, after which a root that is still
|
|
82
|
+
* unfinished is rendered afresh.
|
|
83
|
+
*/
|
|
84
|
+
private waitForParser;
|
|
85
|
+
private adoptRoot;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { attributeName, coerceProperty, parseAttribute, parseProps, serializeAttribute, } from './props.js';
|
|
2
|
+
/**
|
|
3
|
+
* HTMLElement does not exist in Node (SSR, React server rendering). Falling back to an empty
|
|
4
|
+
* class lets every module be imported there; nothing is ever constructed on the server.
|
|
5
|
+
*/
|
|
6
|
+
const ServerSafeHTMLElement = globalThis.HTMLElement ??
|
|
7
|
+
class {
|
|
8
|
+
};
|
|
9
|
+
const sheets = new WeakMap();
|
|
10
|
+
/** Getters that define() installed, as opposed to accessors an author wrote. */
|
|
11
|
+
const installedGetters = new WeakSet();
|
|
12
|
+
function sheetFor(def) {
|
|
13
|
+
let sheet = sheets.get(def);
|
|
14
|
+
if (!sheet) {
|
|
15
|
+
sheet = new CSSStyleSheet();
|
|
16
|
+
sheet.replaceSync(def.styles);
|
|
17
|
+
sheets.set(def, sheet);
|
|
18
|
+
}
|
|
19
|
+
return sheet;
|
|
20
|
+
}
|
|
21
|
+
export class NeuElement extends ServerSafeHTMLElement {
|
|
22
|
+
/** Own properties to re-apply after upgrade besides def.props (spec §6.2 step 3). */
|
|
23
|
+
static extraProperties = [];
|
|
24
|
+
static get observedAttributes() {
|
|
25
|
+
return Object.entries(this.def.props).map(([key, decl]) => attributeName(key, decl));
|
|
26
|
+
}
|
|
27
|
+
/** Installs accessors for def.props and registers the tag. Safe to call in Node. */
|
|
28
|
+
static define() {
|
|
29
|
+
const def = this.def;
|
|
30
|
+
for (const [key, decl] of Object.entries(def.props)) {
|
|
31
|
+
if (this.definesAccessor(key))
|
|
32
|
+
continue;
|
|
33
|
+
const get = function () {
|
|
34
|
+
return this.props[key];
|
|
35
|
+
};
|
|
36
|
+
installedGetters.add(get);
|
|
37
|
+
Object.defineProperty(this.prototype, key, {
|
|
38
|
+
configurable: true,
|
|
39
|
+
enumerable: true,
|
|
40
|
+
get,
|
|
41
|
+
set(value) {
|
|
42
|
+
this.setProp(key, coerceProperty(decl, value));
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const registry = globalThis.customElements;
|
|
47
|
+
if (registry && !registry.get(def.tag)) {
|
|
48
|
+
registry.define(def.tag, this);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True when this class defines `key`, or a class between it and NeuElement wrote its own
|
|
53
|
+
* accessor for it. Accessors that define() installed on a parent do not count: they coerce with
|
|
54
|
+
* the parent's declarations, and this class may declare the prop differently.
|
|
55
|
+
*/
|
|
56
|
+
static definesAccessor(key) {
|
|
57
|
+
if (Object.prototype.hasOwnProperty.call(this.prototype, key))
|
|
58
|
+
return true;
|
|
59
|
+
let proto = Object.getPrototypeOf(this.prototype);
|
|
60
|
+
for (; proto !== null && proto !== NeuElement.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
61
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, key);
|
|
62
|
+
if (descriptor)
|
|
63
|
+
return !(descriptor.get && installedGetters.has(descriptor.get));
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
internals;
|
|
68
|
+
props;
|
|
69
|
+
root = null;
|
|
70
|
+
refs = new Map();
|
|
71
|
+
reflecting = false;
|
|
72
|
+
hasConnected = false;
|
|
73
|
+
formDisabled = false;
|
|
74
|
+
/** The author's tabindex (null when none is set), kept current by syncFocusable. */
|
|
75
|
+
authorTabIndex = null;
|
|
76
|
+
/** The tabindex syncFocusable last left on the host (null: removed; undefined: never ran). */
|
|
77
|
+
writtenTabIndex;
|
|
78
|
+
constructor() {
|
|
79
|
+
super();
|
|
80
|
+
this.internals = this.attachInternals();
|
|
81
|
+
this.props = parseProps(this.def.props, (name) => this.getAttribute(name));
|
|
82
|
+
// Disabled controls emit no events at all, including the native click (spec §6.4).
|
|
83
|
+
this.addEventListener('click', (event) => {
|
|
84
|
+
if (!this.isDisabled)
|
|
85
|
+
return;
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
event.stopImmediatePropagation();
|
|
88
|
+
}, { capture: true });
|
|
89
|
+
}
|
|
90
|
+
connectedCallback() {
|
|
91
|
+
if (!this.hasConnected) {
|
|
92
|
+
this.hasConnected = true;
|
|
93
|
+
this.upgradeProperties();
|
|
94
|
+
this.update(this.allKeys());
|
|
95
|
+
this.ensureRoot();
|
|
96
|
+
}
|
|
97
|
+
this.connected();
|
|
98
|
+
}
|
|
99
|
+
disconnectedCallback() {
|
|
100
|
+
this.disconnected();
|
|
101
|
+
}
|
|
102
|
+
attributeChangedCallback(name, _old, value) {
|
|
103
|
+
if (this.reflecting)
|
|
104
|
+
return;
|
|
105
|
+
for (const [key, decl] of Object.entries(this.def.props)) {
|
|
106
|
+
if (attributeName(key, decl) === name) {
|
|
107
|
+
this.setProp(key, parseAttribute(decl, value), true);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Called by the browser for form-associated subclasses inside a disabled fieldset. */
|
|
113
|
+
formDisabledCallback(disabled) {
|
|
114
|
+
this.formDisabled = disabled;
|
|
115
|
+
this.propChanged('disabled');
|
|
116
|
+
}
|
|
117
|
+
get def() {
|
|
118
|
+
return this.constructor.def;
|
|
119
|
+
}
|
|
120
|
+
get isDisabled() {
|
|
121
|
+
return this.props.disabled === true || this.formDisabled;
|
|
122
|
+
}
|
|
123
|
+
/** Element marked data-ref="name" in the shadow root, or null before the root exists. */
|
|
124
|
+
ref(name) {
|
|
125
|
+
if (!this.root)
|
|
126
|
+
return null;
|
|
127
|
+
let el = this.refs.get(name);
|
|
128
|
+
if (!el) {
|
|
129
|
+
const found = this.root.querySelector(`[data-ref="${name}"]`);
|
|
130
|
+
if (!found)
|
|
131
|
+
return null;
|
|
132
|
+
this.refs.set(name, found);
|
|
133
|
+
el = found;
|
|
134
|
+
}
|
|
135
|
+
return el;
|
|
136
|
+
}
|
|
137
|
+
setProp(key, value, fromAttribute = false) {
|
|
138
|
+
const decl = this.def.props[key];
|
|
139
|
+
if (!decl || Object.is(this.props[key], value))
|
|
140
|
+
return;
|
|
141
|
+
this.props[key] = value;
|
|
142
|
+
if (!fromAttribute && decl.reflect)
|
|
143
|
+
this.reflect(key, decl, value);
|
|
144
|
+
this.propChanged(key);
|
|
145
|
+
}
|
|
146
|
+
toggleState(name, on) {
|
|
147
|
+
if (on)
|
|
148
|
+
this.internals.states.add(name);
|
|
149
|
+
else
|
|
150
|
+
this.internals.states.delete(name);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Focusable: the author's tabindex, or 0. Not focusable: no tabindex at all.
|
|
154
|
+
* A tabindex other than the one this method last left there was set (or removed) by the author
|
|
155
|
+
* since, as a roving-tabindex widget or a framework re-render does; it becomes the author's value.
|
|
156
|
+
*/
|
|
157
|
+
syncFocusable(focusable) {
|
|
158
|
+
const current = this.getAttribute('tabindex');
|
|
159
|
+
if (current !== this.writtenTabIndex)
|
|
160
|
+
this.authorTabIndex = current;
|
|
161
|
+
const wanted = focusable ? (this.authorTabIndex ?? '0') : null;
|
|
162
|
+
if (wanted === null)
|
|
163
|
+
this.removeAttribute('tabindex');
|
|
164
|
+
else if (current !== wanted)
|
|
165
|
+
this.setAttribute('tabindex', wanted);
|
|
166
|
+
this.writtenTabIndex = wanted;
|
|
167
|
+
}
|
|
168
|
+
/** For controls whose host is the control: aria-disabled and focusability follow isDisabled. */
|
|
169
|
+
syncDisabledState() {
|
|
170
|
+
this.internals.ariaDisabled = this.isDisabled ? 'true' : null;
|
|
171
|
+
this.syncFocusable(!this.isDisabled);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Runs on every prop change, connected or not. ElementInternals work (form value, validity) is
|
|
175
|
+
* fine here; leave shadow-DOM and attribute writes to update().
|
|
176
|
+
*/
|
|
177
|
+
willUpdate(_changed) { }
|
|
178
|
+
/** Patches host state and shadow DOM for the changed props. Must be idempotent. */
|
|
179
|
+
update(_changed) { }
|
|
180
|
+
/** Runs once, after the shadow root exists (rendered or adopted from SSR). */
|
|
181
|
+
rendered() { }
|
|
182
|
+
connected() { }
|
|
183
|
+
disconnected() { }
|
|
184
|
+
allKeys() {
|
|
185
|
+
return new Set(Object.keys(this.def.props));
|
|
186
|
+
}
|
|
187
|
+
/** willUpdate, then update once connected, with the same one-key set. */
|
|
188
|
+
propChanged(key) {
|
|
189
|
+
const changed = new Set([key]);
|
|
190
|
+
this.willUpdate(changed);
|
|
191
|
+
if (this.hasConnected)
|
|
192
|
+
this.update(changed);
|
|
193
|
+
}
|
|
194
|
+
reflect(key, decl, value) {
|
|
195
|
+
const attribute = serializeAttribute(decl, value);
|
|
196
|
+
const name = attributeName(key, decl);
|
|
197
|
+
this.reflecting = true;
|
|
198
|
+
try {
|
|
199
|
+
if (attribute === null)
|
|
200
|
+
this.removeAttribute(name);
|
|
201
|
+
else
|
|
202
|
+
this.setAttribute(name, attribute);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
this.reflecting = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
upgradeProperties() {
|
|
209
|
+
const ctor = this.constructor;
|
|
210
|
+
const self = this;
|
|
211
|
+
for (const key of [...Object.keys(ctor.def.props), ...ctor.extraProperties]) {
|
|
212
|
+
if (!Object.prototype.hasOwnProperty.call(this, key))
|
|
213
|
+
continue;
|
|
214
|
+
const value = self[key];
|
|
215
|
+
delete self[key];
|
|
216
|
+
self[key] = value;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** `loaded`: DOMContentLoaded fired, so the parser is done and nothing is left to wait for. */
|
|
220
|
+
ensureRoot(loaded) {
|
|
221
|
+
if (this.root)
|
|
222
|
+
return;
|
|
223
|
+
let root = this.internals.shadowRoot;
|
|
224
|
+
// A root is unfinished while it has fewer data-ref nodes than the template renders.
|
|
225
|
+
const refs = root ? this.def.template(this.props).value.split('data-ref="').length - 1 : 0;
|
|
226
|
+
if (!loaded && document.readyState === 'loading' && this.parsing(root, refs)) {
|
|
227
|
+
this.waitForParser(root, refs);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// No root yet, or one still unfinished after the parser is done (a truncated response):
|
|
231
|
+
// render the template, so rendered() and every update() find their nodes.
|
|
232
|
+
if (!root || this.parsing(root, refs)) {
|
|
233
|
+
root ??= this.attachShadow({
|
|
234
|
+
mode: 'open',
|
|
235
|
+
delegatesFocus: this.def.delegatesFocus ?? false,
|
|
236
|
+
});
|
|
237
|
+
root.innerHTML = this.def.template(this.props).value;
|
|
238
|
+
}
|
|
239
|
+
this.adoptRoot(root);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* While the document loads, the parser may still be writing this element's declarative shadow
|
|
243
|
+
* root (spec §8.1). Before the root exists, that is only possible while nothing has been parsed
|
|
244
|
+
* inside or after the element; one created by script in the middle of the page renders at once.
|
|
245
|
+
* Once it exists, its contents may still be streaming in: it is unfinished while it has fewer
|
|
246
|
+
* than `refs` data-ref nodes, and adopting it then would run rendered() without them.
|
|
247
|
+
*/
|
|
248
|
+
parsing(root, refs) {
|
|
249
|
+
if (root)
|
|
250
|
+
return root.querySelectorAll('[data-ref]').length < refs;
|
|
251
|
+
if (this.firstChild !== null || this.nextSibling !== null)
|
|
252
|
+
return false;
|
|
253
|
+
for (let node = this.parentNode; node !== null; node = node.parentNode) {
|
|
254
|
+
if (node.nextSibling !== null)
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Without a root, watches the document until a child or a following node shows the parser has
|
|
261
|
+
* moved past the element; with an unfinished root, watches that root until it is complete. Then
|
|
262
|
+
* ensureRoot() decides again. DOMContentLoaded is the fallback, after which a root that is still
|
|
263
|
+
* unfinished is rendered afresh.
|
|
264
|
+
*/
|
|
265
|
+
waitForParser(root, refs) {
|
|
266
|
+
const resume = (loaded) => {
|
|
267
|
+
observer.disconnect();
|
|
268
|
+
document.removeEventListener('DOMContentLoaded', resume);
|
|
269
|
+
this.ensureRoot(loaded);
|
|
270
|
+
};
|
|
271
|
+
const observer = new MutationObserver(() => {
|
|
272
|
+
if (!this.parsing(root, refs))
|
|
273
|
+
resume();
|
|
274
|
+
});
|
|
275
|
+
observer.observe(root ?? document, { childList: true, subtree: true });
|
|
276
|
+
document.addEventListener('DOMContentLoaded', resume);
|
|
277
|
+
}
|
|
278
|
+
adoptRoot(root) {
|
|
279
|
+
this.root = root;
|
|
280
|
+
root.adoptedStyleSheets = [sheetFor(this.def)];
|
|
281
|
+
root.querySelector('style[data-neu-ssr]')?.remove();
|
|
282
|
+
this.rendered();
|
|
283
|
+
this.update(this.allKeys());
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type PropDecl = {
|
|
2
|
+
readonly type: 'boolean';
|
|
3
|
+
readonly reflect?: boolean;
|
|
4
|
+
readonly attribute?: string;
|
|
5
|
+
} | {
|
|
6
|
+
readonly type: 'number';
|
|
7
|
+
readonly default: number;
|
|
8
|
+
readonly reflect?: boolean;
|
|
9
|
+
readonly attribute?: string;
|
|
10
|
+
} | {
|
|
11
|
+
readonly type: 'string';
|
|
12
|
+
readonly default: string;
|
|
13
|
+
readonly values: readonly string[];
|
|
14
|
+
readonly reflect?: boolean;
|
|
15
|
+
readonly attribute?: string;
|
|
16
|
+
} | {
|
|
17
|
+
readonly type: 'string';
|
|
18
|
+
readonly default?: string;
|
|
19
|
+
readonly values?: never;
|
|
20
|
+
readonly reflect?: boolean;
|
|
21
|
+
readonly attribute?: string;
|
|
22
|
+
};
|
|
23
|
+
export type PropsSchema = Readonly<Record<string, PropDecl>>;
|
|
24
|
+
export type PropValue = string | number | boolean;
|
|
25
|
+
type InferProp<D> = D extends {
|
|
26
|
+
type: 'boolean';
|
|
27
|
+
} ? boolean : D extends {
|
|
28
|
+
type: 'number';
|
|
29
|
+
} ? number : D extends {
|
|
30
|
+
values: readonly (infer V extends string)[];
|
|
31
|
+
} ? V : string;
|
|
32
|
+
export type InferProps<S extends PropsSchema> = {
|
|
33
|
+
-readonly [K in keyof S]: InferProp<S[K]>;
|
|
34
|
+
};
|
|
35
|
+
/** `helpText` → `help-text`, unless the declaration names the attribute explicitly. */
|
|
36
|
+
export declare function attributeName(prop: string, decl: PropDecl): string;
|
|
37
|
+
/** Attribute text (null when absent) → prop value. Invalid input silently becomes the default. */
|
|
38
|
+
export declare function parseAttribute(decl: PropDecl, raw: string | null): PropValue;
|
|
39
|
+
/** Value assigned through a JS property → prop value, with the same fallback rules. */
|
|
40
|
+
export declare function coerceProperty(decl: PropDecl, value: unknown): PropValue;
|
|
41
|
+
/** Prop value → attribute text for reflection and SSR; null means "remove the attribute". */
|
|
42
|
+
export declare function serializeAttribute(decl: PropDecl, value: PropValue): string | null;
|
|
43
|
+
export declare function parseProps<S extends PropsSchema>(schema: S, read: (attribute: string) => string | null): InferProps<S>;
|
|
44
|
+
export {};
|