u2ui 1.5.13 → 1.5.15
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/el/ico/ico.js +6 -7
- package/js/rte/PLAN.md +65 -3
- package/js/rte/README.md +1 -0
- package/js/rte/ai.js +4 -0
- package/js/rte/playground/index.html +52 -14
- package/js/rte/src/client/README.md +65 -3
- package/js/rte/src/client/ai.js +228 -0
- package/js/rte/src/client/blocks.js +36 -8
- package/js/rte/src/client/classes.js +36 -27
- package/js/rte/src/client/contextual.js +6 -1
- package/js/rte/src/client/editor.js +130 -0
- package/js/rte/src/client/link.js +37 -8
- package/js/rte/src/command/block-style.js +11 -1
- package/js/rte/src/command/commands.js +1 -2
- package/js/rte/src/command/edit.js +12 -2
- package/js/rte/src/command/enter.js +6 -1
- package/js/rte/src/config/README.md +30 -3
- package/js/rte/src/config/config.js +49 -11
- package/js/rte/src/core/core.js +3 -1
- package/js/rte/src/input/input-pipeline.js +6 -4
- package/js/rte/src/sanitize/README.md +26 -8
- package/js/rte/src/sanitize/native.js +2 -0
- package/js/rte/src/sanitize/policy.js +38 -8
- package/js/rte/src/source/source.js +4 -1
- package/js/rte/src/surface/surface.js +10 -1
- package/js/rte/src/ui/README.md +5 -0
- package/js/rte/src/ui/caret.js +19 -1
- package/js/rte/src/ui/toolbar.js +64 -7
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ import {editingHost, isPlainTextHost} from '../selection/ownership/ownership.js'
|
|
|
8
8
|
import {Point} from '../selection/point/point.js';
|
|
9
9
|
import {SelectionSnapshot} from '../selection/snapshot.js';
|
|
10
10
|
import {defaultUnstyle} from '../unstyle/unstyle.js';
|
|
11
|
-
import {sanitizePolicy} from '../sanitize/policy.js';
|
|
11
|
+
import {policyFor, sanitizePolicy} from '../sanitize/policy.js';
|
|
12
12
|
|
|
13
13
|
const TRIGGERS = new Set(['input', 'paste', 'drop', 'command']);
|
|
14
14
|
const PASTE = new Set(['insertFromPaste', 'insertFromPasteAsQuotation']);
|
|
@@ -109,11 +109,13 @@ export class InputPipeline {
|
|
|
109
109
|
// A native paste is the one import the browser inserts itself, so
|
|
110
110
|
// this is where the policy reaches it: elements first, because
|
|
111
111
|
// unwrapping moves nodes, then attributes.
|
|
112
|
-
|
|
112
|
+
// The host may declare its own attributes and protocols; everything else stays as configured.
|
|
113
|
+
const sanitize = this.#sanitize && policyFor(settings, this.#sanitize);
|
|
114
|
+
if (sanitize && settings.importSanitize === 'policy' && imported?.roots.length) {
|
|
113
115
|
// Attributes first: removing them moves nothing, while narrowing
|
|
114
116
|
// dissolves wrappers and would leave nothing left to clean.
|
|
115
117
|
for (const root of imported.roots) {
|
|
116
|
-
|
|
118
|
+
sanitize.clean(root, {
|
|
117
119
|
preserve: imported.preserve,
|
|
118
120
|
base: this.#root.ownerDocument.baseURI,
|
|
119
121
|
classes: settings.classes.length ? settings.classes : null,
|
|
@@ -121,7 +123,7 @@ export class InputPipeline {
|
|
|
121
123
|
}
|
|
122
124
|
const map = new PointMap(points);
|
|
123
125
|
for (const root of imported.roots) {
|
|
124
|
-
|
|
126
|
+
sanitize.narrow(root, {
|
|
125
127
|
map,
|
|
126
128
|
preserve: imported.preserve,
|
|
127
129
|
elements: importable(settings),
|
|
@@ -8,17 +8,21 @@ repairs editable document shape.
|
|
|
8
8
|
|
|
9
9
|
## Policy
|
|
10
10
|
|
|
11
|
-
`SanitizePolicy` is an immutable allowlist.
|
|
11
|
+
`SanitizePolicy` is an immutable allowlist. It speaks the vocabulary of the web
|
|
12
|
+
Sanitizer API, so one set of terms carries through browser, editor, and whatever
|
|
13
|
+
an application filters with; where the API has no answer, the policy extends it
|
|
14
|
+
rather than renaming what it has. Its options are:
|
|
12
15
|
|
|
13
16
|
- `elements`: element names the security policy can accept;
|
|
14
17
|
- `attributes`: global (`*`) and per-element attribute names;
|
|
15
|
-
- `protocols`: per-element rules for URL attributes
|
|
16
|
-
|
|
18
|
+
- `protocols`: per-element rules for URL attributes — the one extension, because
|
|
19
|
+
`clean()` also runs on nodes a browser inserted, where no sink checked them;
|
|
20
|
+
- `removeElements`: element names that `narrow()` removes with their content, before it
|
|
17
21
|
asks `skip` — no later stage may be left holding them;
|
|
18
22
|
- `comments` and `dataAttributes`: explicit booleans, both false by default.
|
|
19
23
|
|
|
20
24
|
An element outside `elements` keeps its content — dropping it would lose text the
|
|
21
|
-
author wrote. `
|
|
25
|
+
author wrote. `removeElements` names the exception: elements a browser never renders the
|
|
22
26
|
children of, where unwrapping would turn a stylesheet, a script, or document
|
|
23
27
|
metadata into visible text. It defaults to `base head link meta noscript script
|
|
24
28
|
style template title` and, like every default, an application may replace it.
|
|
@@ -27,11 +31,23 @@ The supplied `sanitizePolicy` accepts the `document` element preset, ordinary
|
|
|
27
31
|
editor metadata, and conservative web URLs. An image may carry a `data:` URL,
|
|
28
32
|
because an image executes nothing — not even an SVG one, which browsers draw in
|
|
29
33
|
a script-free context; in a navigating attribute the same protocol is a page, so
|
|
30
|
-
links and citations keep it out.
|
|
31
|
-
|
|
34
|
+
links and citations keep it out. There is no allowlist of css properties, for the
|
|
35
|
+
same reason the Sanitizer API has none: `style` is an attribute like any other,
|
|
36
|
+
and taking presentation away is Unstyle's job.
|
|
37
|
+
Event attributes, comments, and data attributes
|
|
38
|
+
are not enabled by default. Inline `style` is: it executes nothing, and where
|
|
39
|
+
foreign presentation is unwanted the Unstyle policy already removes it from what
|
|
40
|
+
an import brought — a policy that must not carry it at all narrows `attributes`.
|
|
32
41
|
Relative URLs use the explicit `relative` protocol token. Known URL attributes
|
|
33
42
|
without a matching protocol rule are removed rather than accepted implicitly.
|
|
34
43
|
|
|
44
|
+
`policy.with(options)` copies a policy with single axes replaced, and
|
|
45
|
+
`policyFor(config, base)` is what a host gets: its own `--u2-rte-elements`,
|
|
46
|
+
`--u2-rte-attributes` and `--u2-rte-protocols` over the policy the application
|
|
47
|
+
chose — each axis declared is that axis, not a narrowing of it — with equal
|
|
48
|
+
declarations sharing one policy instead of building it again. Nothing declared
|
|
49
|
+
returns the base itself.
|
|
50
|
+
|
|
35
51
|
```js
|
|
36
52
|
const policy = new SanitizePolicy({
|
|
37
53
|
elements: ['p', 'a', 'strong'],
|
|
@@ -72,6 +88,8 @@ rich paste/drop events and direct HTML imports.
|
|
|
72
88
|
- Attribute and URL policy remains separate from structural normalization.
|
|
73
89
|
- Sanitizing creates no editor state, listeners, or markers.
|
|
74
90
|
- Policies do not mutate after construction.
|
|
91
|
+
- A configuration never carries `elements` and `removeElements` together: the api
|
|
92
|
+
rejects that pair, and `setHTML()` then yields nothing.
|
|
75
93
|
|
|
76
94
|
## Where the policy is applied
|
|
77
95
|
|
|
@@ -83,8 +101,8 @@ Three paths bring external HTML in, and all three now meet the same policy:
|
|
|
83
101
|
there and no fallback parser is needed, but the result still has to obey the
|
|
84
102
|
same attribute policy — so the input pipeline applies `clean()` to the nodes
|
|
85
103
|
that arrived. Until it did, the most common import was the only one without an
|
|
86
|
-
allowlist, and layout ids
|
|
87
|
-
|
|
104
|
+
allowlist, and layout ids and tracking `data-` attributes came straight through.
|
|
105
|
+
Inline styles are not that kind of problem: Unstyle takes them off an import.
|
|
88
106
|
|
|
89
107
|
`clean(root, {preserve})` spares elements that were already there, so a paste
|
|
90
108
|
never re-cleans the document it landed in.
|
|
@@ -39,5 +39,7 @@ function config(policy, elements) {
|
|
|
39
39
|
dataAttributes: policy.dataAttributes,
|
|
40
40
|
};
|
|
41
41
|
if (replaced.length) result.replaceWithChildrenElements = replaced;
|
|
42
|
+
// No `removeElements` beside an `elements` allowlist: the api rejects a config carrying both, and
|
|
43
|
+
// an allowlist already excludes them — what they must not leave behind is `narrow()`'s job.
|
|
42
44
|
return result;
|
|
43
45
|
}
|
|
@@ -4,8 +4,11 @@ const NAME = /^[a-z][a-z\d-]*$/;
|
|
|
4
4
|
const ATTRIBUTE = /^[a-z][a-z\d_.:-]*$/;
|
|
5
5
|
const PROTOCOL = /^[a-z][a-z\d+.-]*$/;
|
|
6
6
|
const URL_ATTRIBUTES = new Set(['action', 'cite', 'formaction', 'href', 'poster', 'src', 'xlink:href']);
|
|
7
|
+
// `style` is presentation, not execution: inline css runs no script, and where foreign styling is
|
|
8
|
+
// unwanted the Unstyle policy already removes it from what an import brought — a question of taste
|
|
9
|
+
// with a stage of its own. An application that must not carry it at all narrows this policy.
|
|
7
10
|
const DEFAULT_ATTRIBUTES = Object.freeze({
|
|
8
|
-
'*': Object.freeze(['class', 'dir', 'lang', 'title']),
|
|
11
|
+
'*': Object.freeze(['class', 'dir', 'lang', 'style', 'title']),
|
|
9
12
|
a: Object.freeze(['href', 'rel', 'target']),
|
|
10
13
|
blockquote: Object.freeze(['cite']),
|
|
11
14
|
img: Object.freeze(['alt', 'height', 'loading', 'src', 'width']),
|
|
@@ -30,7 +33,7 @@ const DEFAULT_PROTOCOLS = Object.freeze({
|
|
|
30
33
|
// Elements a browser never renders the children of. Unwrapping one turns source — a stylesheet, a
|
|
31
34
|
// script, document metadata — into visible text, so these go with their content. Every other unknown
|
|
32
35
|
// element keeps its content: dropping it would lose text the author wrote.
|
|
33
|
-
const
|
|
36
|
+
const DEFAULT_REMOVE = Object.freeze([
|
|
34
37
|
'base', 'head', 'link', 'meta', 'noscript', 'script', 'style', 'template', 'title',
|
|
35
38
|
]);
|
|
36
39
|
|
|
@@ -38,7 +41,7 @@ export const sanitizeDefaults = Object.freeze({
|
|
|
38
41
|
elements: elementPresets.document,
|
|
39
42
|
attributes: DEFAULT_ATTRIBUTES,
|
|
40
43
|
protocols: DEFAULT_PROTOCOLS,
|
|
41
|
-
|
|
44
|
+
removeElements: DEFAULT_REMOVE,
|
|
42
45
|
comments: false,
|
|
43
46
|
dataAttributes: false,
|
|
44
47
|
});
|
|
@@ -49,7 +52,7 @@ export class SanitizePolicy {
|
|
|
49
52
|
|
|
50
53
|
constructor(options = {}) {
|
|
51
54
|
this.elements = names(options.elements ?? sanitizeDefaults.elements, NAME, 'element');
|
|
52
|
-
this.
|
|
55
|
+
this.removeElements = names(options.removeElements ?? sanitizeDefaults.removeElements, NAME, 'element');
|
|
53
56
|
this.#attributes = groups(options.attributes ?? sanitizeDefaults.attributes, ATTRIBUTE, 'attribute');
|
|
54
57
|
this.#protocols = protocolGroups(options.protocols ?? sanitizeDefaults.protocols);
|
|
55
58
|
this.attributeNames = Object.freeze(unique(Object.values(this.#attributes).flat()));
|
|
@@ -58,6 +61,14 @@ export class SanitizePolicy {
|
|
|
58
61
|
Object.freeze(this);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/** A copy with some axes replaced: what a host declares, on top of what the application chose. */
|
|
65
|
+
with(options) {
|
|
66
|
+
return new SanitizePolicy({
|
|
67
|
+
elements: this.elements, removeElements: this.removeElements, attributes: this.#attributes,
|
|
68
|
+
protocols: this.#protocols, comments: this.comments, dataAttributes: this.dataAttributes, ...options,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
allowsAttribute(element, attribute) {
|
|
62
73
|
const name = element.localName.toLowerCase();
|
|
63
74
|
const attributeName = attribute.toLowerCase();
|
|
@@ -78,15 +89,15 @@ export class SanitizePolicy {
|
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
// Reduces a subtree to the elements this policy allows, keeping the content
|
|
81
|
-
// of the rest — except the `
|
|
92
|
+
// of the rest — except the `removeElements` ones, which go with theirs. Parsed input
|
|
82
93
|
// gets this from the safe sink; markup the browser inserted itself has to be
|
|
83
94
|
// narrowed afterwards.
|
|
84
95
|
//
|
|
85
96
|
// `skip` leaves an element to a later stage. Structural repair already
|
|
86
97
|
// removes what the content model rejects, and it does so knowing that
|
|
87
98
|
// dissolving a block into inline content needs a line break to survive.
|
|
88
|
-
// A
|
|
89
|
-
//
|
|
99
|
+
// A removed element is not that kind of question — no later stage may keep its
|
|
100
|
+
// content — so it goes before `skip` is asked.
|
|
90
101
|
//
|
|
91
102
|
// `alias` names an equivalent for an element that is not allowed. Dropping
|
|
92
103
|
// `<b>` would lose the emphasis it carries, so a strict list can still keep
|
|
@@ -99,7 +110,7 @@ export class SanitizePolicy {
|
|
|
99
110
|
const changed = [];
|
|
100
111
|
for (const element of descendants(root).reverse()) {
|
|
101
112
|
if (preserve?.has(element) || !element.parentNode) continue;
|
|
102
|
-
if (this.
|
|
113
|
+
if (this.removeElements.includes(element.localName)) {
|
|
103
114
|
if (map) map.remove(element);
|
|
104
115
|
else element.remove();
|
|
105
116
|
changed.push(element);
|
|
@@ -147,6 +158,25 @@ export class SanitizePolicy {
|
|
|
147
158
|
|
|
148
159
|
export const sanitizePolicy = new SanitizePolicy();
|
|
149
160
|
|
|
161
|
+
// The policy a host works with: what it declares is what it may carry, and the rest stays as the
|
|
162
|
+
// application configured it. Declaring is rare and reading is not, so equal declarations share one
|
|
163
|
+
// policy instead of building it again.
|
|
164
|
+
const declared = new WeakMap();
|
|
165
|
+
|
|
166
|
+
export function policyFor({elements = null, attributes = null, protocols = null} = {}, base = sanitizePolicy) {
|
|
167
|
+
if (!elements && !attributes && !protocols) return base;
|
|
168
|
+
const key = JSON.stringify([elements, attributes, protocols]);
|
|
169
|
+
let known = declared.get(base);
|
|
170
|
+
if (!known) declared.set(base, known = new Map());
|
|
171
|
+
let policy = known.get(key);
|
|
172
|
+
if (!policy) {
|
|
173
|
+
known.set(key, policy = base.with({
|
|
174
|
+
...(elements && {elements}), ...(attributes && {attributes}), ...(protocols && {protocols}),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
return policy;
|
|
178
|
+
}
|
|
179
|
+
|
|
150
180
|
function names(values, pattern, label) {
|
|
151
181
|
if (!values || typeof values[Symbol.iterator] !== 'function' || typeof values === 'string') {
|
|
152
182
|
throw new TypeError(`${label} names must be an iterable`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {htmlModel} from '../model/html/html-model.js';
|
|
2
2
|
import {narrow} from '../command/edit.js';
|
|
3
3
|
import {NativeSanitizer} from '../sanitize/native.js';
|
|
4
|
+
import {policyFor} from '../sanitize/policy.js';
|
|
4
5
|
import {SelectionSnapshot} from '../selection/snapshot.js';
|
|
5
6
|
import {replaceContent} from '../surface/content.js';
|
|
6
7
|
|
|
@@ -21,7 +22,9 @@ export class Source {
|
|
|
21
22
|
#sanitizer;
|
|
22
23
|
#indent;
|
|
23
24
|
|
|
24
|
-
constructor(surface, {sanitizer =
|
|
25
|
+
constructor(surface, {sanitizer = null, indent = ' '} = {}) {
|
|
26
|
+
// Without one supplied, the host's own declarations decide what may be written back.
|
|
27
|
+
sanitizer ??= new NativeSanitizer(policyFor(surface?.config));
|
|
25
28
|
if (surface?.element?.nodeType !== Node.ELEMENT_NODE || typeof surface?.transact !== 'function') {
|
|
26
29
|
throw new TypeError('A source view requires an editor surface');
|
|
27
30
|
}
|
|
@@ -7,6 +7,7 @@ export class Surface extends EventTarget {
|
|
|
7
7
|
#element;
|
|
8
8
|
#options;
|
|
9
9
|
#selection = null;
|
|
10
|
+
#settings = null;
|
|
10
11
|
#transaction = null;
|
|
11
12
|
#active = false;
|
|
12
13
|
#connected = true;
|
|
@@ -21,7 +22,15 @@ export class Surface extends EventTarget {
|
|
|
21
22
|
get core() { return this.#core; }
|
|
22
23
|
get element() { return this.#element; }
|
|
23
24
|
get options() { return this.#options; }
|
|
24
|
-
|
|
25
|
+
// One reading per burst of work: a refresh asks dozens of times, and a computed style is the
|
|
26
|
+
// expensive part of a path every keystroke goes down. The next task reads again, and anything
|
|
27
|
+
// that changes a host property in this one says so with `invalidate()` — as `refresh()` does.
|
|
28
|
+
get config() { return this.#settings ??= config(this.#element); }
|
|
29
|
+
|
|
30
|
+
invalidate() {
|
|
31
|
+
this.#settings = null;
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
25
34
|
get selection() { return this.#selection; }
|
|
26
35
|
get transaction() { return this.#transaction; }
|
|
27
36
|
get active() { return this.#active; }
|
package/js/rte/src/ui/README.md
CHANGED
|
@@ -55,6 +55,11 @@ item, boolean and `'mixed'` command states become `aria-pressed`, so toggle
|
|
|
55
55
|
buttons need no separate adapter. Action buttons omit `data-state` and never
|
|
56
56
|
pretend to be toggles merely because their command can report state.
|
|
57
57
|
`data-control` may give a button a CSS-facing name different from its command,
|
|
58
|
+
A declared `--u2-rte-toolbar` names the controls in the order they are shown; the
|
|
59
|
+
toolbar arranges its markup to match, and anything it does not name keeps the
|
|
60
|
+
order it was built in, after the rest. Nothing moves while the declaration stays
|
|
61
|
+
the same.
|
|
62
|
+
|
|
58
63
|
just as it does for a value control. This lets `--u2-rte-toolbar: breaks` expose
|
|
59
64
|
a button whose command is `showBreaks`.
|
|
60
65
|
|
package/js/rte/src/ui/caret.js
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
// surface takes the focus first, so what it captures is a selection it owns.
|
|
5
5
|
//
|
|
6
6
|
// An element that is no longer there leaves the surface with what it had.
|
|
7
|
+
// The same hand-back where there is no element left: emptying an address takes the link away, and
|
|
8
|
+
// what follows it is the end of the text it used to be.
|
|
9
|
+
export function caretAfterRange(surface, range) {
|
|
10
|
+
surface.element.focus();
|
|
11
|
+
if (!range || !surface.element.contains(range.endContainer)) return surface.restore();
|
|
12
|
+
const selection = surface.core.selection;
|
|
13
|
+
const collapsed = range.cloneRange();
|
|
14
|
+
collapsed.collapse(false);
|
|
15
|
+
selection.removeAllRanges();
|
|
16
|
+
selection.addRange(collapsed);
|
|
17
|
+
return !!surface.capture();
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export function caretAfter(surface, element) {
|
|
8
21
|
surface.element.focus();
|
|
9
22
|
if (!element?.isConnected) return surface.restore();
|
|
10
23
|
const range = surface.element.ownerDocument.createRange();
|
|
11
|
-
|
|
24
|
+
// The start of what follows, rather than the boundary behind the element: engines resolve a
|
|
25
|
+
// caret at that boundary towards the element it sits behind, and typing would go on inside the
|
|
26
|
+
// link that was just left. Naming the text node instead says the same place without the pull.
|
|
27
|
+
const next = element.nextSibling;
|
|
28
|
+
if (next?.nodeType === Node.TEXT_NODE) range.setStart(next, 0);
|
|
29
|
+
else range.setStartAfter(element);
|
|
12
30
|
range.collapse(true);
|
|
13
31
|
const selection = surface.core.selection;
|
|
14
32
|
selection.removeAllRanges();
|
package/js/rte/src/ui/toolbar.js
CHANGED
|
@@ -9,6 +9,7 @@ export class Toolbar {
|
|
|
9
9
|
#surface = null;
|
|
10
10
|
#commands = null;
|
|
11
11
|
#connected = true;
|
|
12
|
+
#ordered = null;
|
|
12
13
|
|
|
13
14
|
constructor(core, element, {commands, place = null} = {}) {
|
|
14
15
|
if (!core?.root || typeof core?.addEventListener !== 'function') {
|
|
@@ -60,6 +61,7 @@ export class Toolbar {
|
|
|
60
61
|
refresh() {
|
|
61
62
|
if (!this.#connected) return false;
|
|
62
63
|
const surface = this.#surface;
|
|
64
|
+
surface?.invalidate(); // whatever a host declared may have changed since the last one
|
|
63
65
|
const commands = surface?.connected ? this.#resolve(surface) : null;
|
|
64
66
|
if (commands != null && !registry(commands)) throw new TypeError('Toolbar commands must resolve to a command registry');
|
|
65
67
|
this.#commands = commands || null;
|
|
@@ -70,6 +72,7 @@ export class Toolbar {
|
|
|
70
72
|
const style = commands && getComputedStyle(surface.element);
|
|
71
73
|
const active = !!commands && surface.config.ui === 'roaming' && visibleForSelection(surface, style);
|
|
72
74
|
const names = active ? configured(style) : null;
|
|
75
|
+
this.#order(names);
|
|
73
76
|
// A host may prefer a toolbar that only ever shows what it can do, at
|
|
74
77
|
// the cost of a shape that moves with the caret.
|
|
75
78
|
const hiding = active && setting(style, 'toolbar-unavailable') === 'hide';
|
|
@@ -78,7 +81,7 @@ export class Toolbar {
|
|
|
78
81
|
for (const item of this.#items()) {
|
|
79
82
|
const name = item.dataset.command.trim();
|
|
80
83
|
const control = item.dataset.control?.trim() || name;
|
|
81
|
-
const offered = active && commands.has(name) && (!names || names.
|
|
84
|
+
const offered = active && commands.has(name) && (!names || names.includes(control));
|
|
82
85
|
const disabled = !offered || !commands.enabled(name, detail);
|
|
83
86
|
item.hidden = !offered || hiding && disabled;
|
|
84
87
|
if (item.hidden) {
|
|
@@ -88,10 +91,35 @@ export class Toolbar {
|
|
|
88
91
|
visible++;
|
|
89
92
|
state(item, item.hasAttribute('data-state') ? commands.state(name, detail) : null, disabled);
|
|
90
93
|
}
|
|
94
|
+
// A menu button offers what its entries do: present when the command is, available while at
|
|
95
|
+
// least one entry is, and each entry marked with the value its set currently carries.
|
|
96
|
+
for (const button of this.#element.querySelectorAll('button[data-command-menu]')) {
|
|
97
|
+
const command = button.dataset.commandMenu.trim();
|
|
98
|
+
const control = button.dataset.control?.trim() || command;
|
|
99
|
+
const show = active && commands.has(command) && (!names || names.includes(control));
|
|
100
|
+
const panel = this.#element.querySelector(`[data-menu="${command}"]`);
|
|
101
|
+
let usable = 0;
|
|
102
|
+
for (const entry of panel?.querySelectorAll('button[data-value]') ?? []) {
|
|
103
|
+
const value = entry.dataset.value;
|
|
104
|
+
const on = show && commands.state(command, {...detail, value}) === value;
|
|
105
|
+
// What is already set stays in the list, marked: an entry the selection carries has
|
|
106
|
+
// nothing left to apply, and hiding it would take the answer away with the question.
|
|
107
|
+
const enabled = on || show && commands.enabled(command, {...detail, value});
|
|
108
|
+
entry.disabled = !enabled;
|
|
109
|
+
entry.hidden = !enabled;
|
|
110
|
+
entry.setAttribute('aria-pressed', String(on));
|
|
111
|
+
if (enabled) usable++;
|
|
112
|
+
}
|
|
113
|
+
const offered = show && !!panel?.querySelector('button[data-value]');
|
|
114
|
+
button.hidden = !offered || hiding && !usable;
|
|
115
|
+
state(button, null, !usable);
|
|
116
|
+
if (button.hidden && panel) panel.hidden = true;
|
|
117
|
+
else visible++;
|
|
118
|
+
}
|
|
91
119
|
for (const select of this.#values()) {
|
|
92
120
|
const command = select.dataset.commandValue.trim();
|
|
93
121
|
const control = select.dataset.control?.trim() || command;
|
|
94
|
-
const show = active && commands.has(command) && (!names || names.
|
|
122
|
+
const show = active && commands.has(command) && (!names || names.includes(control));
|
|
95
123
|
let choices = 0;
|
|
96
124
|
for (const option of select.options) {
|
|
97
125
|
if (!option.value) continue;
|
|
@@ -167,6 +195,21 @@ export class Toolbar {
|
|
|
167
195
|
this.refresh();
|
|
168
196
|
}
|
|
169
197
|
|
|
198
|
+
// The declaration is the order, not only the choice: a host says what its toolbar looks like,
|
|
199
|
+
// and what it does not name keeps the order its modules were registered in, behind the rest.
|
|
200
|
+
// Only a changed declaration moves anything — this runs on every refresh.
|
|
201
|
+
#order(names) {
|
|
202
|
+
const key = names?.join(' ') ?? '';
|
|
203
|
+
if (this.#ordered === key) return;
|
|
204
|
+
this.#ordered = key;
|
|
205
|
+
if (!names) return;
|
|
206
|
+
for (const name of [...names].reverse()) {
|
|
207
|
+
const control = this.#element.querySelector(`[data-control="${name}"]`);
|
|
208
|
+
const panel = control?.nextElementSibling?.hasAttribute('data-menu') ? control.nextElementSibling : null;
|
|
209
|
+
if (control) this.#element.prepend(control, ...(panel ? [panel] : []));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
170
213
|
#items() {
|
|
171
214
|
return this.#element.querySelectorAll('[data-command]');
|
|
172
215
|
}
|
|
@@ -198,11 +241,23 @@ export class Toolbar {
|
|
|
198
241
|
// Fields keep their own pointer behaviour so they can be opened and typed in.
|
|
199
242
|
#pointerDown = event => {
|
|
200
243
|
const target = event.composedPath()[0];
|
|
201
|
-
|
|
244
|
+
// Fields keep their own pointer behaviour so they can be opened and typed in — but only
|
|
245
|
+
// while they can take the focus. A disabled one would send it nowhere, and a session ends
|
|
246
|
+
// where its focus went.
|
|
247
|
+
const field = target?.closest?.('select, input, textarea');
|
|
248
|
+
if (field && !field.disabled) return;
|
|
202
249
|
event.preventDefault();
|
|
203
250
|
};
|
|
204
251
|
|
|
205
252
|
#click = event => {
|
|
253
|
+
// A menu entry sets a value the way a select does; the menu stays open, so a second set can
|
|
254
|
+
// be picked without opening it again.
|
|
255
|
+
const entry = event.target?.closest?.('button[data-command-value][data-value]');
|
|
256
|
+
if (entry && this.#element.contains(entry) && !entry.disabled) {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
this.#run(entry.dataset.commandValue.trim(), {value: entry.dataset.value});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
206
261
|
const item = this.#item(event.target);
|
|
207
262
|
if (!item) return;
|
|
208
263
|
event.preventDefault();
|
|
@@ -235,7 +290,7 @@ function setting(style, name) {
|
|
|
235
290
|
|
|
236
291
|
function configured(style) {
|
|
237
292
|
const value = setting(style, 'toolbar');
|
|
238
|
-
return value ? new Set(value.split(/[\s,]+/).filter(Boolean)) : null;
|
|
293
|
+
return value ? [...new Set(value.split(/[\s,]+/).filter(Boolean))] : null;
|
|
239
294
|
}
|
|
240
295
|
|
|
241
296
|
function visibleForSelection(surface, style) {
|
|
@@ -250,9 +305,11 @@ function state(item, value, disabled) {
|
|
|
250
305
|
else item.removeAttribute('aria-pressed');
|
|
251
306
|
}
|
|
252
307
|
|
|
308
|
+
// Hidden before it leaves the top layer, shown after it enters: the two attributes hide the same
|
|
309
|
+
// element, and `togglePopover(force)` says which state is wanted rather than asking for the current.
|
|
253
310
|
function display(element, visible) {
|
|
254
|
-
const popover = element.hasAttribute('popover') && typeof element.
|
|
255
|
-
if (!visible && popover
|
|
311
|
+
const popover = element.hasAttribute('popover') && typeof element.togglePopover === 'function';
|
|
312
|
+
if (!visible && popover) element.togglePopover(false);
|
|
256
313
|
element.hidden = !visible;
|
|
257
|
-
if (visible && popover
|
|
314
|
+
if (visible && popover) element.togglePopover(true);
|
|
258
315
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u2ui",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.15",
|
|
4
4
|
"description": "HTML Advanced: More Capabilities, Same Feel, Same Simplicity",
|
|
5
5
|
"keywords": ["web-components","pattern-library","component-library","ui-toolkit","design-system"],
|
|
6
6
|
"author": "Tobias Buschor",
|