@threadlabs/looma 0.28.7 → 0.28.8
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/components/ui-editor-link-form/ui-editor-link-form.html +118 -0
- package/components/ui-editor-link-form/ui-editor-link-form.js +71 -0
- package/dist/index.js +33 -32
- package/package.json +1 -1
- package/styles/ui-editor-link-form.css +1 -0
- package/vanilla/UiEditorLinkForm.d.ts +32 -0
- package/vanilla/UiEditorLinkForm.js +354 -0
- package/vanilla/index.js +1 -0
- package/vue/UiEditorLinkForm.d.ts +165 -0
- package/vue/UiEditorLinkForm.js +2 -0
- package/vue/UiEditorLinkForm.vue +557 -0
- package/vue/chunks/UiEditorLinkForm.js +669 -0
- package/vue/components.css +407 -407
- package/vue/editor/index.js +72 -159
- package/vue/editor/primitives.d.ts +17 -1
- package/vue/index.d.ts +1 -0
- package/vue/index.js +4 -3
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<link rel="component" href="../ui-stack/ui-stack.html">
|
|
2
|
+
<link rel="component" href="../ui-text/ui-text.html">
|
|
3
|
+
<link rel="component" href="../ui-form-field/ui-form-field.html">
|
|
4
|
+
<link rel="component" href="../ui-input/ui-input.html">
|
|
5
|
+
<link rel="component" href="../ui-input-group/ui-input-group.html">
|
|
6
|
+
<link rel="component" href="../ui-icon/ui-icon.html">
|
|
7
|
+
<link rel="component" href="../ui-icon-button/ui-icon-button.html">
|
|
8
|
+
<link rel="component" href="../ui-scroll-area/ui-scroll-area.html">
|
|
9
|
+
<link rel="component" href="../ui-search-result-row/ui-search-result-row.html">
|
|
10
|
+
<link rel="component" href="../ui-checkbox/ui-checkbox.html">
|
|
11
|
+
<template component="ui-editor-link-form" controller="./ui-editor-link-form.js">
|
|
12
|
+
<defs>
|
|
13
|
+
<prop name="existing" type="boolean" default="false">Whether the link already exists, so the heading reads Edit link rather than Add link.</prop>
|
|
14
|
+
<prop name="searchable" type="boolean" default="false"
|
|
15
|
+
>Whether the destination field searches for a destination as well as taking an address. A searchable field has a search icon and a Clear search
|
|
16
|
+
button.</prop>
|
|
17
|
+
<prop name="mode" type="keyword" values="url, target" default="url"
|
|
18
|
+
>What the destination field holds: an address, which offers Open in new tab, or a search, which lists results.</prop>
|
|
19
|
+
<prop name="value" type="string" default="">The destination field's text: the address, or the search.</prop>
|
|
20
|
+
<prop name="placeholder" type="string" default="https://example.com">The destination field's placeholder.</prop>
|
|
21
|
+
<prop name="newTab" type="boolean" default="false">Whether Open in new tab is checked.</prop>
|
|
22
|
+
<prop name="textEditable" type="boolean" default="false">Shows a Text field, for a link added where no text is selected.</prop>
|
|
23
|
+
<prop name="text" type="string" default="">The Text field's value.</prop>
|
|
24
|
+
<prop name="error" type="string" default="">A problem to show under the fields, announced as an alert.</prop>
|
|
25
|
+
<prop name="status" type="string" default=""
|
|
26
|
+
>A line above the results, such as Searching… or No results found., announced politely. Shown only for a search.</prop>
|
|
27
|
+
<!-- default="[]": without it the generated Vue v-for receives null (HTML Next item 7; html-next #236 treats a missing list as empty for the next alpha). -->
|
|
28
|
+
<prop name="results" type="list(object({ id: string, label: string, detail?: string }))" default="[]">The destinations a search found.</prop>
|
|
29
|
+
<prop name="selected" type="string" default="">The id of the chosen result, which shows a check.</prop>
|
|
30
|
+
<state name="base" type="string" value="ui-editor-link-form"></state>
|
|
31
|
+
<computed name="heading" from="{ true: 'Edit link', false: 'Add link' }[concat($existing)]"></computed>
|
|
32
|
+
<computed name="fieldLabel" from="{ true: 'Link destination', false: 'URL' }[concat($searchable)]"></computed>
|
|
33
|
+
<event name="destination" type="object">
|
|
34
|
+
<prop name="value" required type="string"></prop>
|
|
35
|
+
The destination field changed.
|
|
36
|
+
</event>
|
|
37
|
+
<event name="text" type="object">
|
|
38
|
+
<prop name="text" required type="string"></prop>
|
|
39
|
+
The Text field changed.
|
|
40
|
+
</event>
|
|
41
|
+
<event name="new-tab" type="object">
|
|
42
|
+
<prop name="newTab" required type="boolean"></prop>
|
|
43
|
+
Open in new tab changed.
|
|
44
|
+
</event>
|
|
45
|
+
<event name="choose" type="object">
|
|
46
|
+
<prop name="id" required type="string"></prop>
|
|
47
|
+
A result was chosen.
|
|
48
|
+
</event>
|
|
49
|
+
<event name="clear" type="object">Clear search was pressed.</event>
|
|
50
|
+
<event name="save" type="object">Enter was pressed in a field, to keep the link and close the form.</event>
|
|
51
|
+
<event name="dismiss" type="object">Escape was pressed, to close the form.</event>
|
|
52
|
+
</defs>
|
|
53
|
+
<form aria-label="Edit link">
|
|
54
|
+
<ui-stack gap="s">
|
|
55
|
+
<ui-text as="h3" size="sm" weight="semibold">{$heading}</ui-text>
|
|
56
|
+
<ui-stack gap="s">
|
|
57
|
+
<ui-form-field from:invalid="$error != ''">
|
|
58
|
+
<label slot="label" from:for="concat($base, '-destination')">{$fieldLabel}</label>
|
|
59
|
+
<ui-input-group $if="$searchable" prefix-kind="icon">
|
|
60
|
+
<ui-icon slot="prefix" from:name="{ true: 'search', false: 'link' }[concat($mode = 'target')]"></ui-icon>
|
|
61
|
+
<ui-input
|
|
62
|
+
data-link-destination
|
|
63
|
+
data-link-search
|
|
64
|
+
from:id="concat($base, '-destination')"
|
|
65
|
+
type="text"
|
|
66
|
+
role="searchbox"
|
|
67
|
+
autocomplete="off"
|
|
68
|
+
from:placeholder="$placeholder"
|
|
69
|
+
from:value="$value"
|
|
70
|
+
></ui-input>
|
|
71
|
+
<ui-icon-button $if="$value != ''" slot="action" label="Clear search" variant="ghost" size="sm" data-link-clear><ui-icon
|
|
72
|
+
name="x"
|
|
73
|
+
></ui-icon></ui-icon-button>
|
|
74
|
+
</ui-input-group>
|
|
75
|
+
<template $if="not $searchable">
|
|
76
|
+
<ui-input
|
|
77
|
+
data-link-destination
|
|
78
|
+
from:id="concat($base, '-destination')"
|
|
79
|
+
type="text"
|
|
80
|
+
inputmode="url"
|
|
81
|
+
size="sm"
|
|
82
|
+
from:placeholder="$placeholder"
|
|
83
|
+
from:value="$value"
|
|
84
|
+
></ui-input>
|
|
85
|
+
</template>
|
|
86
|
+
</ui-form-field>
|
|
87
|
+
<ui-scroll-area $if="$mode = 'target'" size="sm" aria-label="Destination search results">
|
|
88
|
+
<ui-text $if="$status != ''" size="sm" tone="secondary" role="status">{$status}</ui-text>
|
|
89
|
+
<ui-search-result-row
|
|
90
|
+
$each="result of $results"
|
|
91
|
+
$key="$result.id"
|
|
92
|
+
density="compact"
|
|
93
|
+
from:selected="$result.id = $selected"
|
|
94
|
+
data-link-result
|
|
95
|
+
from:data-link-id="$result.id"
|
|
96
|
+
>
|
|
97
|
+
<ui-icon slot="leading" name="file-text"></ui-icon>
|
|
98
|
+
<!-- from:truncate="true": a bare boolean converts to truncate="" in Vue (HTML Next gap 2, fixed by html-next #236 for the next alpha). -->
|
|
99
|
+
<ui-text slot="title" as="strong" size="sm" weight="medium" from:truncate="true" from:title="$result.label">{$result.label}</ui-text>
|
|
100
|
+
<ui-text $if="$result.detail" slot="meta" as="span" size="xs" tone="secondary" from:truncate="true" from:title="$result.detail"
|
|
101
|
+
>{$result.detail}</ui-text>
|
|
102
|
+
<ui-icon $if="$result.id = $selected" slot="trailing" name="check"></ui-icon>
|
|
103
|
+
</ui-search-result-row>
|
|
104
|
+
</ui-scroll-area>
|
|
105
|
+
<template $if="$mode = 'url'">
|
|
106
|
+
<ui-checkbox size="sm" from:checked="$newTab" data-link-new-tab>Open in new tab</ui-checkbox>
|
|
107
|
+
</template>
|
|
108
|
+
</ui-stack>
|
|
109
|
+
<template $if="$textEditable">
|
|
110
|
+
<ui-form-field>
|
|
111
|
+
<label slot="label" from:for="concat($base, '-text')">Text</label>
|
|
112
|
+
<ui-input data-link-text from:id="concat($base, '-text')" size="sm" from:value="$text"></ui-input>
|
|
113
|
+
</ui-form-field>
|
|
114
|
+
</template>
|
|
115
|
+
<ui-text $if="$error != ''" size="sm" tone="danger" role="alert">{$error}</ui-text>
|
|
116
|
+
</ui-stack>
|
|
117
|
+
</form>
|
|
118
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Gives each form its own field ids, reports field changes and choices, saves on Enter in a field,
|
|
2
|
+
// closes on Escape, and moves between the search field and its results with the arrow keys.
|
|
3
|
+
function connect(host) {
|
|
4
|
+
const element = host.element;
|
|
5
|
+
if (host.state.base === "ui-editor-link-form") host.state.base = `ui-editor-link-form-${Math.random().toString(36).slice(2, 11)}`;
|
|
6
|
+
const within = (event, selector) => {
|
|
7
|
+
const match = event.target.closest?.(selector);
|
|
8
|
+
return match && element.contains(match) ? match : null;
|
|
9
|
+
};
|
|
10
|
+
const onInput = (event) => {
|
|
11
|
+
if (within(event, "[data-link-destination]")) host.dispatch("destination", { value: event.target.value });
|
|
12
|
+
else if (within(event, "[data-link-text]")) host.dispatch("text", { text: event.target.value });
|
|
13
|
+
};
|
|
14
|
+
// Checkbox reports its own change with the new state; the native change it wraps carries none.
|
|
15
|
+
const onChange = (event) => {
|
|
16
|
+
if (within(event, "[data-link-new-tab]") && typeof event.detail?.checked === "boolean") host.dispatch("new-tab", { newTab: event.detail.checked });
|
|
17
|
+
};
|
|
18
|
+
const onClick = (event) => {
|
|
19
|
+
const result = within(event, "[data-link-result]");
|
|
20
|
+
if (result) host.dispatch("choose", { id: result.dataset.linkId });
|
|
21
|
+
else if (within(event, "[data-link-clear]")) {
|
|
22
|
+
host.dispatch("clear", {});
|
|
23
|
+
element.querySelector("[data-link-search]")?.focus();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const moveFocus = (event, offset) => {
|
|
27
|
+
const results = [...element.querySelectorAll("[data-link-result]")];
|
|
28
|
+
const next = results[results.indexOf(event.target) + offset];
|
|
29
|
+
if (next) next.focus();
|
|
30
|
+
else if (offset < 0) element.querySelector("[data-link-search]")?.focus();
|
|
31
|
+
else return;
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
};
|
|
34
|
+
// The form handles Enter and Escape itself, so the overlay around it does not also act on them.
|
|
35
|
+
const onKeyDown = (event) => {
|
|
36
|
+
if (event.key === "Escape") {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
event.stopPropagation();
|
|
39
|
+
host.dispatch("dismiss", {});
|
|
40
|
+
} else if (event.key === "Enter" && event.target instanceof HTMLInputElement) {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
host.dispatch("save", {});
|
|
44
|
+
} else if (event.key === "ArrowDown" && (within(event, "[data-link-result]") || (within(event, "[data-link-search]") && host.props.mode.value === "target"))) {
|
|
45
|
+
moveFocus(event, 1);
|
|
46
|
+
} else if (event.key === "ArrowUp" && within(event, "[data-link-result]")) {
|
|
47
|
+
moveFocus(event, -1);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const onSubmit = (event) => {
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
host.dispatch("save", {});
|
|
53
|
+
};
|
|
54
|
+
element.addEventListener("input", onInput);
|
|
55
|
+
element.addEventListener("change", onChange);
|
|
56
|
+
element.addEventListener("click", onClick);
|
|
57
|
+
element.addEventListener("keydown", onKeyDown);
|
|
58
|
+
element.addEventListener("submit", onSubmit);
|
|
59
|
+
return () => {
|
|
60
|
+
element.removeEventListener("input", onInput);
|
|
61
|
+
element.removeEventListener("change", onChange);
|
|
62
|
+
element.removeEventListener("click", onClick);
|
|
63
|
+
element.removeEventListener("keydown", onKeyDown);
|
|
64
|
+
element.removeEventListener("submit", onSubmit);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keep DOM setup and its cleanup tied to each connection, including reconnects. */
|
|
69
|
+
export default function controller(host) {
|
|
70
|
+
host.on("connect", () => connect(host));
|
|
71
|
+
}
|