@syware/jlib 0.4.0 → 0.5.1
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/all.js +8 -3
- package/dist/atoms/jl-badge.d.ts +12 -1
- package/dist/atoms/jl-badge.js +15 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -2
- package/dist/lib/time.d.ts +10 -0
- package/dist/lib/time.js +64 -0
- package/dist/molecules/jl-account-button.css_raw.js +3 -0
- package/dist/molecules/jl-account-button.d.ts +46 -0
- package/dist/molecules/jl-account-button.js +97 -0
- package/dist/molecules/jl-load-state.css_raw.js +3 -0
- package/dist/molecules/jl-load-state.d.ts +61 -0
- package/dist/molecules/jl-load-state.js +200 -0
- package/dist/molecules/jl-search-button.css_raw.js +3 -0
- package/dist/molecules/jl-search-button.d.ts +34 -0
- package/dist/molecules/jl-search-button.js +80 -0
- package/dist/molecules/jl-tooltip.css_raw.js +1 -1
- package/dist/molecules/jl-tooltip.d.ts +26 -1
- package/dist/molecules/jl-tooltip.js +132 -5
- package/dist/organisms/jl-confirm.d.ts +7 -1
- package/dist/organisms/jl-confirm.js +22 -1
- package/dist/organisms/jl-toaster.d.ts +4 -1
- package/dist/organisms/jl-toaster.js +9 -2
- package/dist/styles/control.css_raw.js +1 -1
- package/dist/styles/tokens.css_raw.js +1 -1
- package/dist/templates/jl-page.css_raw.js +3 -0
- package/dist/templates/jl-page.d.ts +49 -0
- package/dist/templates/jl-page.js +102 -0
- package/dist/tokens.css +22 -0
- package/dist/types/jl-badge.types.d.ts +11 -0
- package/dist/types/jl-confirm.types.d.ts +15 -0
- package/dist/types/jl-load-state.types.d.ts +10 -0
- package/dist/types/jl-page.types.d.ts +2 -0
- package/dist/types/jl-toaster.types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { BASE_STYLES } from "../styles/shared.js";
|
|
2
|
+
import { JlElement } from "../base/jl-element.js";
|
|
3
|
+
import { clampedNumber, oneOf } from "../lib/attributes.js";
|
|
4
|
+
import { define } from "../lib/define.js";
|
|
5
|
+
import { registerIcons } from "../lib/icons.js";
|
|
6
|
+
import { JlButton } from "../atoms/jl-button.js";
|
|
7
|
+
import { JlSkeleton } from "../atoms/jl-skeleton.js";
|
|
8
|
+
import { JlEmpty } from "./jl-empty.js";
|
|
9
|
+
import jl_load_state_default from "./jl-load-state.css_raw.js";
|
|
10
|
+
import { css } from "@jadis/core";
|
|
11
|
+
import { CircleAlert, RefreshCw } from "lucide";
|
|
12
|
+
import { jsx, jsxs } from "@jadis/core/jsx-runtime";
|
|
13
|
+
// src/molecules/jl-load-state.tsx
|
|
14
|
+
registerIcons({
|
|
15
|
+
"circle-alert": CircleAlert,
|
|
16
|
+
refresh: RefreshCw
|
|
17
|
+
});
|
|
18
|
+
define(JlEmpty);
|
|
19
|
+
var LOAD_STATES = [
|
|
20
|
+
"loading",
|
|
21
|
+
"ready",
|
|
22
|
+
"empty",
|
|
23
|
+
"error"
|
|
24
|
+
];
|
|
25
|
+
var LOAD_SKELETONS = [
|
|
26
|
+
"rows",
|
|
27
|
+
"cards",
|
|
28
|
+
"lines"
|
|
29
|
+
];
|
|
30
|
+
var DEFAULT_ERROR_HEADING = "This could not be loaded";
|
|
31
|
+
var DEFAULT_ERROR_TEXT = "Check the connection, then try again. Nothing was changed.";
|
|
32
|
+
var DEFAULT_RETRY_LABEL = "Try again";
|
|
33
|
+
var DEFAULT_DELAY_MS = 300;
|
|
34
|
+
var MAX_DELAY_MS = 5e3;
|
|
35
|
+
var DEFAULT_COUNT = 3;
|
|
36
|
+
var MAX_COUNT = 24;
|
|
37
|
+
var LINE_WIDTHS = [
|
|
38
|
+
"100%",
|
|
39
|
+
"92%",
|
|
40
|
+
"96%",
|
|
41
|
+
"70%"
|
|
42
|
+
];
|
|
43
|
+
/** The skeleton of each shape, `count` times. */
|
|
44
|
+
var SKELETONS = {
|
|
45
|
+
cards: (count) => Array.from({ length: count }, () => /* @__PURE__ */ jsxs("div", {
|
|
46
|
+
class: "bone-card",
|
|
47
|
+
children: [
|
|
48
|
+
/* @__PURE__ */ jsx(JlSkeleton, {
|
|
49
|
+
shape: "rect",
|
|
50
|
+
height: "120px"
|
|
51
|
+
}),
|
|
52
|
+
/* @__PURE__ */ jsx(JlSkeleton, { width: "70%" }),
|
|
53
|
+
/* @__PURE__ */ jsx(JlSkeleton, { width: "45%" })
|
|
54
|
+
]
|
|
55
|
+
})),
|
|
56
|
+
lines: (count) => Array.from({ length: count }, (_, index) => /* @__PURE__ */ jsx(JlSkeleton, { width: LINE_WIDTHS[index % LINE_WIDTHS.length] })),
|
|
57
|
+
rows: (count) => Array.from({ length: count }, () => /* @__PURE__ */ jsxs("div", {
|
|
58
|
+
class: "bone-row",
|
|
59
|
+
children: [/* @__PURE__ */ jsx(JlSkeleton, {
|
|
60
|
+
shape: "circle",
|
|
61
|
+
width: "32px",
|
|
62
|
+
height: "32px"
|
|
63
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
64
|
+
class: "bone-lines",
|
|
65
|
+
children: [/* @__PURE__ */ jsx(JlSkeleton, { width: "60%" }), /* @__PURE__ */ jsx(JlSkeleton, { width: "35%" })]
|
|
66
|
+
})]
|
|
67
|
+
}))
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* What a list or a page shows while its data comes: nothing for a moment, then a skeleton shaped like it; then the
|
|
71
|
+
* data (the default slot), what to say when there is none (slot `empty`, a `jl-empty`), or, when it failed, what
|
|
72
|
+
* failed and Try again. `load()` fetches, draws and moves between the states; a later `load()` keeps what is shown
|
|
73
|
+
* until the new data is there, so live updates do not flash. Or set `state` yourself.
|
|
74
|
+
*/
|
|
75
|
+
var JlLoadState = class extends JlElement {
|
|
76
|
+
static selector = "jl-load-state";
|
|
77
|
+
events = this.useEvents();
|
|
78
|
+
state = this.useChange("loading", (value) => this.renderState(value), {
|
|
79
|
+
attribute: "state",
|
|
80
|
+
parse: oneOf(LOAD_STATES, "loading")
|
|
81
|
+
});
|
|
82
|
+
/** The shape of the skeleton; slot `loading` takes one of the page's own instead. */
|
|
83
|
+
skeleton = this.useChange("rows", () => this.renderSkeleton(), {
|
|
84
|
+
attribute: "skeleton",
|
|
85
|
+
parse: oneOf(LOAD_SKELETONS, "rows")
|
|
86
|
+
});
|
|
87
|
+
/** How many rows, cards or lines the skeleton has. */
|
|
88
|
+
count = this.useChange(DEFAULT_COUNT, () => this.renderSkeleton(), {
|
|
89
|
+
attribute: "count",
|
|
90
|
+
parse: clampedNumber(DEFAULT_COUNT, 1, MAX_COUNT)
|
|
91
|
+
});
|
|
92
|
+
/** Milliseconds before the skeleton shows. */
|
|
93
|
+
delay = this.useChange(DEFAULT_DELAY_MS, () => void 0, {
|
|
94
|
+
attribute: "delay",
|
|
95
|
+
parse: clampedNumber(DEFAULT_DELAY_MS, 0, MAX_DELAY_MS)
|
|
96
|
+
});
|
|
97
|
+
errorHeading = this.useChange(DEFAULT_ERROR_HEADING, (value) => this._refs.error.heading.set(value), { attribute: "error-heading" });
|
|
98
|
+
errorText = this.useChange(DEFAULT_ERROR_TEXT, (value) => this._refs.error.description.set(value), { attribute: "error-text" });
|
|
99
|
+
retryLabel = this.useChange(DEFAULT_RETRY_LABEL, (value) => this._refs.retry.label.set(value), { attribute: "retry-label" });
|
|
100
|
+
_work = {};
|
|
101
|
+
_refs = this.useRefs((ref) => ({
|
|
102
|
+
bones: ref(".bones"),
|
|
103
|
+
content: ref(".content"),
|
|
104
|
+
empty: ref(".empty"),
|
|
105
|
+
error: ref("jl-empty"),
|
|
106
|
+
loading: ref(".loading"),
|
|
107
|
+
retry: ref(".retry")
|
|
108
|
+
}));
|
|
109
|
+
/**
|
|
110
|
+
* Fetches the data and draws it. The first time, and after a failure, the skeleton shows while it comes; later
|
|
111
|
+
* (a refresh), what is shown stays until the new data is there, and stays too if it fails. Resolves to whether it
|
|
112
|
+
* worked: a refresh that fails says so only here, for the page to tell people.
|
|
113
|
+
*/
|
|
114
|
+
async load(task) {
|
|
115
|
+
this._work.retry = () => this.load(task);
|
|
116
|
+
const showing = this.state.get() === "ready" || this.state.get() === "empty";
|
|
117
|
+
if (!showing) this.state.set("loading");
|
|
118
|
+
try {
|
|
119
|
+
const value = await task.fetch();
|
|
120
|
+
task.draw(value);
|
|
121
|
+
this.state.set(task.isEmpty?.(value) === true ? "empty" : "ready");
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
if (!showing) this.state.set("error");
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
templateHtml() {
|
|
129
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
130
|
+
class: "load-state",
|
|
131
|
+
attrs: { part: "base" },
|
|
132
|
+
children: [
|
|
133
|
+
/* @__PURE__ */ jsx("div", {
|
|
134
|
+
class: "loading",
|
|
135
|
+
hidden: true,
|
|
136
|
+
"aria-busy": "true",
|
|
137
|
+
children: /* @__PURE__ */ jsx("slot", {
|
|
138
|
+
name: "loading",
|
|
139
|
+
children: /* @__PURE__ */ jsx("div", { class: "bones" })
|
|
140
|
+
})
|
|
141
|
+
}),
|
|
142
|
+
/* @__PURE__ */ jsx("jl-empty", {
|
|
143
|
+
class: "error",
|
|
144
|
+
icon: "circle-alert",
|
|
145
|
+
heading: DEFAULT_ERROR_HEADING,
|
|
146
|
+
description: DEFAULT_ERROR_TEXT,
|
|
147
|
+
hidden: true,
|
|
148
|
+
children: /* @__PURE__ */ jsx(JlButton, {
|
|
149
|
+
class: "retry",
|
|
150
|
+
icon: "refresh",
|
|
151
|
+
label: DEFAULT_RETRY_LABEL
|
|
152
|
+
})
|
|
153
|
+
}),
|
|
154
|
+
/* @__PURE__ */ jsx("div", {
|
|
155
|
+
class: "empty",
|
|
156
|
+
hidden: true,
|
|
157
|
+
children: /* @__PURE__ */ jsx("slot", { name: "empty" })
|
|
158
|
+
}),
|
|
159
|
+
/* @__PURE__ */ jsx("div", {
|
|
160
|
+
class: "content",
|
|
161
|
+
hidden: true,
|
|
162
|
+
children: /* @__PURE__ */ jsx("slot", {})
|
|
163
|
+
})
|
|
164
|
+
]
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
templateCss() {
|
|
168
|
+
return css`${BASE_STYLES}${jl_load_state_default}`;
|
|
169
|
+
}
|
|
170
|
+
onConnect() {
|
|
171
|
+
this.renderSkeleton();
|
|
172
|
+
this.renderState(this.state.get());
|
|
173
|
+
this._refs.retry.events.register("press", () => {
|
|
174
|
+
this.events.emit("retry");
|
|
175
|
+
this._work.retry?.();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
onDisconnect() {
|
|
179
|
+
clearTimeout(this._work.timer);
|
|
180
|
+
}
|
|
181
|
+
renderState(state) {
|
|
182
|
+
const refs = this._refs;
|
|
183
|
+
clearTimeout(this._work.timer);
|
|
184
|
+
refs.loading.hidden = true;
|
|
185
|
+
refs.error.hidden = state !== "error";
|
|
186
|
+
refs.empty.hidden = state !== "empty";
|
|
187
|
+
refs.content.hidden = state !== "ready";
|
|
188
|
+
this.reflect("state", state);
|
|
189
|
+
if (state === "loading") this._work.timer = setTimeout(() => {
|
|
190
|
+
refs.loading.hidden = false;
|
|
191
|
+
}, this.delay.get());
|
|
192
|
+
}
|
|
193
|
+
renderSkeleton() {
|
|
194
|
+
const kind = this.skeleton.get();
|
|
195
|
+
this._refs.bones.className = `bones ${kind}`;
|
|
196
|
+
this._refs.bones.replaceChildren(...SKELETONS[kind](this.count.get()));
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
JlLoadState.register();
|
|
200
|
+
export { JlLoadState, LOAD_SKELETONS, LOAD_STATES };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// src/molecules/jl-search-button.css?raw
|
|
2
|
+
var jl_search_button_default = ":host {\n display: block;\n}\n\n.search {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n height: var(--jl-control-sm);\n padding: 0 6px 0 10px;\n border: 1px solid var(--jl-line);\n border-radius: var(--jl-radius-sm);\n background: var(--jl-surface);\n color: var(--jl-muted);\n font-size: var(--jl-font-sm);\n cursor: pointer;\n transition: border-color var(--jl-fast) var(--jl-ease);\n}\n\n.search:hover {\n border-color: var(--jl-line-strong);\n color: var(--jl-text-2);\n}\n\n.text {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-align: left;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* Folded: the icon alone, as square as a control. */\n:host([compact]) {\n display: inline-block;\n}\n\n:host([compact]) .search {\n justify-content: center;\n width: var(--jl-control-sm);\n padding: 0;\n}\n\n:host([compact]) .text,\n:host([compact]) jl-kbd {\n display: none;\n}\n\n/* Touch screens have no keys to press. */\n@media (pointer: coarse) {\n jl-kbd {\n display: none;\n }\n}\n";
|
|
3
|
+
export { jl_search_button_default as default };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { JlElement } from '../base/jl-element';
|
|
2
|
+
/**
|
|
3
|
+
* What opens an app's command palette: it looks like a search field, with the palette's keys (Ctrl K, ⌘K on a
|
|
4
|
+
* Mac). `compact` keeps the icon alone, for a folded side nav or a phone's top bar. Listen to `press`; the keys are
|
|
5
|
+
* the palette's own (`jl-command-palette` listens to them), the button only shows them.
|
|
6
|
+
*/
|
|
7
|
+
export declare class JlSearchButton extends JlElement {
|
|
8
|
+
static readonly selector = "jl-search-button";
|
|
9
|
+
readonly events: Readonly<import("@jadis/core").UseEventsHandler<{
|
|
10
|
+
press: undefined;
|
|
11
|
+
}>>;
|
|
12
|
+
/** The text, and the button's name: "Search". */
|
|
13
|
+
readonly label: Readonly<Readonly<{
|
|
14
|
+
get(): string;
|
|
15
|
+
set(setter: string | ((prevState: string) => string)): void;
|
|
16
|
+
}>>;
|
|
17
|
+
/** The keys that open the palette, as `jl-kbd` writes them: `mod+k`; `none` for no keys. */
|
|
18
|
+
readonly hotkey: Readonly<Readonly<{
|
|
19
|
+
get(): string;
|
|
20
|
+
set(setter: string | ((prevState: string) => string)): void;
|
|
21
|
+
}>>;
|
|
22
|
+
/** The icon alone, the label as its tooltip. */
|
|
23
|
+
readonly compact: Readonly<Readonly<{
|
|
24
|
+
get(): boolean;
|
|
25
|
+
set(setter: boolean | ((prevState: boolean) => boolean)): void;
|
|
26
|
+
}>>;
|
|
27
|
+
private readonly _refs;
|
|
28
|
+
focus(options?: FocusOptions): void;
|
|
29
|
+
templateHtml(): Node;
|
|
30
|
+
templateCss(): string;
|
|
31
|
+
onConnect(): void;
|
|
32
|
+
private renderLabel;
|
|
33
|
+
private renderHotkey;
|
|
34
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { BASE_STYLES } from "../styles/shared.js";
|
|
2
|
+
import { JlElement } from "../base/jl-element.js";
|
|
3
|
+
import { registerIcons } from "../lib/icons.js";
|
|
4
|
+
import { setOrRemove } from "../lib/dom.js";
|
|
5
|
+
import { JlIcon } from "../atoms/jl-icon.js";
|
|
6
|
+
import { JlKbd } from "../atoms/jl-kbd.js";
|
|
7
|
+
import { parseShortcut, shortcutAria } from "../lib/key-shortcut.js";
|
|
8
|
+
import jl_search_button_default from "./jl-search-button.css_raw.js";
|
|
9
|
+
import { css } from "@jadis/core";
|
|
10
|
+
import { Search } from "lucide";
|
|
11
|
+
import { jsx, jsxs } from "@jadis/core/jsx-runtime";
|
|
12
|
+
// src/molecules/jl-search-button.tsx
|
|
13
|
+
registerIcons({ search: Search });
|
|
14
|
+
var DEFAULT_LABEL = "Search";
|
|
15
|
+
var DEFAULT_HOTKEY = "mod+k";
|
|
16
|
+
var NO_HOTKEY = "none";
|
|
17
|
+
var ICON_SIZE = 15;
|
|
18
|
+
var MAC_PATTERN = /Mac|iPhone|iPad|iPod/;
|
|
19
|
+
var isMac = () => MAC_PATTERN.test(navigator.userAgent);
|
|
20
|
+
/**
|
|
21
|
+
* What opens an app's command palette: it looks like a search field, with the palette's keys (Ctrl K, ⌘K on a
|
|
22
|
+
* Mac). `compact` keeps the icon alone, for a folded side nav or a phone's top bar. Listen to `press`; the keys are
|
|
23
|
+
* the palette's own (`jl-command-palette` listens to them), the button only shows them.
|
|
24
|
+
*/
|
|
25
|
+
var JlSearchButton = class extends JlElement {
|
|
26
|
+
static selector = "jl-search-button";
|
|
27
|
+
events = this.useEvents();
|
|
28
|
+
/** The text, and the button's name: "Search". */
|
|
29
|
+
label = this.useChange(DEFAULT_LABEL, () => this.renderLabel(), { attribute: "label" });
|
|
30
|
+
/** The keys that open the palette, as `jl-kbd` writes them: `mod+k`; `none` for no keys. */
|
|
31
|
+
hotkey = this.useChange(DEFAULT_HOTKEY, () => this.renderHotkey(), { attribute: "hotkey" });
|
|
32
|
+
/** The icon alone, the label as its tooltip. */
|
|
33
|
+
compact = this.useChange(false, (value) => this.reflect("compact", value), { attribute: "compact" });
|
|
34
|
+
_refs = this.useRefs((ref) => ({
|
|
35
|
+
button: ref("button"),
|
|
36
|
+
keys: ref("jl-kbd"),
|
|
37
|
+
text: ref(".text")
|
|
38
|
+
}));
|
|
39
|
+
focus(options) {
|
|
40
|
+
this._refs.button.focus(options);
|
|
41
|
+
}
|
|
42
|
+
templateHtml() {
|
|
43
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
44
|
+
type: "button",
|
|
45
|
+
class: "search",
|
|
46
|
+
attrs: { part: "base" },
|
|
47
|
+
children: [
|
|
48
|
+
/* @__PURE__ */ jsx(JlIcon, {
|
|
49
|
+
name: "search",
|
|
50
|
+
size: ICON_SIZE
|
|
51
|
+
}),
|
|
52
|
+
/* @__PURE__ */ jsx("span", { class: "text" }),
|
|
53
|
+
/* @__PURE__ */ jsx(JlKbd, {})
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
templateCss() {
|
|
58
|
+
return css`${BASE_STYLES}${jl_search_button_default}`;
|
|
59
|
+
}
|
|
60
|
+
onConnect() {
|
|
61
|
+
this.renderLabel();
|
|
62
|
+
this.renderHotkey();
|
|
63
|
+
this.on(this._refs.button, "click", () => this.events.emit("press"));
|
|
64
|
+
}
|
|
65
|
+
renderLabel() {
|
|
66
|
+
const label = this.label.get() || DEFAULT_LABEL;
|
|
67
|
+
this._refs.text.textContent = label;
|
|
68
|
+
this._refs.button.setAttribute("aria-label", label);
|
|
69
|
+
this._refs.button.title = label;
|
|
70
|
+
}
|
|
71
|
+
renderHotkey() {
|
|
72
|
+
const text = this.hotkey.get();
|
|
73
|
+
const shortcut = text === NO_HOTKEY ? null : parseShortcut(text);
|
|
74
|
+
this._refs.keys.keys.set(shortcut === null ? "" : text);
|
|
75
|
+
this._refs.keys.hidden = shortcut === null;
|
|
76
|
+
setOrRemove(this._refs.button, "aria-keyshortcuts", shortcut === null ? "" : shortcutAria(shortcut, isMac()));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
JlSearchButton.register();
|
|
80
|
+
export { JlSearchButton };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// src/molecules/jl-tooltip.css?raw
|
|
2
|
-
var jl_tooltip_default = ":host {\n display: inline-flex;\n vertical-align: middle;\n}\n\n/* Ink on the page's ground: dark on a light theme, light on a dark one. */\n.bubble {\n max-width: min(260px, calc(100vw - 16px));\n padding: 6px 9px;\n border-radius: var(--jl-radius-xs);\n background: var(--jl-text);\n color: var(--jl-bg);\n font-size: var(--jl-font-xs);\n font-weight: 550;\n line-height: 1.4;\n box-shadow: var(--jl-shadow);\n pointer-events: auto;\n}\n\n.arrow {\n position: absolute;\n width: 8px;\n height: 8px;\n border-radius: 1px;\n background: inherit;\n rotate: 45deg;\n}\n\n.bubble[data-placement^=\"top\"] .arrow {\n top: 100%;\n left: var(--arrow, 50%);\n translate: -50% -50%;\n}\n\n.bubble[data-placement^=\"bottom\"] .arrow {\n bottom: 100%;\n left: var(--arrow, 50%);\n translate: -50% 50%;\n}\n\n.bubble[data-placement^=\"left\"] .arrow {\n top: var(--arrow, 50%);\n left: 100%;\n translate: -50% -50%;\n}\n\n.bubble[data-placement^=\"right\"] .arrow {\n top: var(--arrow, 50%);\n right: 100%;\n translate: 50% -50%;\n}\n";
|
|
2
|
+
var jl_tooltip_default = ":host {\n display: inline-flex;\n vertical-align: middle;\n /* A long press shows the bubble: no callout over it, and (below) no text selected under the finger. */\n -webkit-touch-callout: none;\n}\n\n@media (pointer: coarse) {\n ::slotted(*) {\n -webkit-user-select: none;\n user-select: none;\n }\n}\n\n/* Ink on the page's ground: dark on a light theme, light on a dark one. */\n.bubble {\n max-width: min(260px, calc(100vw - 16px));\n padding: 6px 9px;\n border-radius: var(--jl-radius-xs);\n background: var(--jl-text);\n color: var(--jl-bg);\n font-size: var(--jl-font-xs);\n font-weight: 550;\n line-height: 1.4;\n box-shadow: var(--jl-shadow);\n pointer-events: auto;\n}\n\n.arrow {\n position: absolute;\n width: 8px;\n height: 8px;\n border-radius: 1px;\n background: inherit;\n rotate: 45deg;\n}\n\n.bubble[data-placement^=\"top\"] .arrow {\n top: 100%;\n left: var(--arrow, 50%);\n translate: -50% -50%;\n}\n\n.bubble[data-placement^=\"bottom\"] .arrow {\n bottom: 100%;\n left: var(--arrow, 50%);\n translate: -50% 50%;\n}\n\n.bubble[data-placement^=\"left\"] .arrow {\n top: var(--arrow, 50%);\n left: 100%;\n translate: -50% -50%;\n}\n\n.bubble[data-placement^=\"right\"] .arrow {\n top: var(--arrow, 50%);\n right: 100%;\n translate: 50% -50%;\n}\n";
|
|
3
3
|
export { jl_tooltip_default as default };
|
|
@@ -2,7 +2,13 @@ import { JlElement } from '../base/jl-element';
|
|
|
2
2
|
import type { Placement } from '../types/floating.types';
|
|
3
3
|
/**
|
|
4
4
|
* A short hint in a dark bubble by its trigger (the slotted element): it shows on hover and on keyboard focus after
|
|
5
|
-
* a `delay`, and hides on leave, blur and Escape. The trigger gets the text as its accessible description.
|
|
5
|
+
* a `delay`, and hides on leave, blur and Escape. The trigger gets the text as its accessible description. While
|
|
6
|
+
* the pointer or the focus is on it, the trigger's own `title` (a button's `hint`) is set aside, so the browser's
|
|
7
|
+
* tooltip does not show over the bubble.
|
|
8
|
+
*
|
|
9
|
+
* Touch screens have no hover: pressing and holding a trigger shows the bubble (and the press does not act), and
|
|
10
|
+
* it stays a moment after the finger lifts; a trigger that does nothing on a tap (a word, an icon) shows or hides
|
|
11
|
+
* it on a tap.
|
|
6
12
|
*/
|
|
7
13
|
export declare class JlTooltip extends JlElement {
|
|
8
14
|
static readonly selector = "jl-tooltip";
|
|
@@ -31,6 +37,25 @@ export declare class JlTooltip extends JlElement {
|
|
|
31
37
|
/** Hides the bubble now. */
|
|
32
38
|
hide(): void;
|
|
33
39
|
private close;
|
|
40
|
+
/** A trigger that acts on a tap (a button, a link, a field), as opposed to a word or an icon. */
|
|
41
|
+
private isInteractive;
|
|
42
|
+
private pressStart;
|
|
43
|
+
private pressMove;
|
|
44
|
+
/** The finger lifted, or the page took it to scroll: a bubble the press showed stays a moment, then goes. */
|
|
45
|
+
private pressEnd;
|
|
46
|
+
/** The system's own long press: the bubble shows now, and the menu it would open does not, but over a link. */
|
|
47
|
+
private pressHeld;
|
|
48
|
+
private openFromPress;
|
|
49
|
+
private swallowLongPress;
|
|
50
|
+
private hideBubble;
|
|
51
|
+
/**
|
|
52
|
+
* Sets aside the native `title` of the trigger and of its inner control: the bubble says it, and the browser's
|
|
53
|
+
* own tooltip would show over it a moment later. The control keeps its name for screen readers (a button's hint
|
|
54
|
+
* is its `aria-label` too).
|
|
55
|
+
*/
|
|
56
|
+
private holdTitles;
|
|
57
|
+
/** Gives the titles back, unless the trigger wrote new ones meanwhile. */
|
|
58
|
+
private restoreTitles;
|
|
34
59
|
private trigger;
|
|
35
60
|
private isOpen;
|
|
36
61
|
private scheduleShow;
|
|
@@ -14,10 +14,22 @@ var HIDE_GRACE_MS = 120;
|
|
|
14
14
|
var GAP = 9;
|
|
15
15
|
var ARROW_MARGIN = 10;
|
|
16
16
|
var DESCRIPTION = "aria-description";
|
|
17
|
+
var TITLE = "title";
|
|
18
|
+
var TOUCH = "touch";
|
|
19
|
+
var LONG_PRESS_MS = 500;
|
|
20
|
+
var MOVE_TOLERANCE_PX = 10;
|
|
21
|
+
var TOUCH_LINGER_MS = 1500;
|
|
22
|
+
var INTERACTIVE = "a[href], button, input, select, textarea, summary, [role=\"button\"], [role=\"link\"]";
|
|
17
23
|
var sideOf = (placement) => placement.split("-")[0];
|
|
18
24
|
/**
|
|
19
25
|
* A short hint in a dark bubble by its trigger (the slotted element): it shows on hover and on keyboard focus after
|
|
20
|
-
* a `delay`, and hides on leave, blur and Escape. The trigger gets the text as its accessible description.
|
|
26
|
+
* a `delay`, and hides on leave, blur and Escape. The trigger gets the text as its accessible description. While
|
|
27
|
+
* the pointer or the focus is on it, the trigger's own `title` (a button's `hint`) is set aside, so the browser's
|
|
28
|
+
* tooltip does not show over the bubble.
|
|
29
|
+
*
|
|
30
|
+
* Touch screens have no hover: pressing and holding a trigger shows the bubble (and the press does not act), and
|
|
31
|
+
* it stays a moment after the finger lifts; a trigger that does nothing on a tap (a word, an icon) shows or hides
|
|
32
|
+
* it on a tap.
|
|
21
33
|
*/
|
|
22
34
|
var JlTooltip = class extends JlElement {
|
|
23
35
|
static selector = "jl-tooltip";
|
|
@@ -35,7 +47,15 @@ var JlTooltip = class extends JlElement {
|
|
|
35
47
|
_state = {
|
|
36
48
|
described: null,
|
|
37
49
|
following: new AbortController(),
|
|
50
|
+
heldTitles: [],
|
|
38
51
|
hideTimer: 0,
|
|
52
|
+
press: {
|
|
53
|
+
down: false,
|
|
54
|
+
opened: false,
|
|
55
|
+
timer: 0,
|
|
56
|
+
x: 0,
|
|
57
|
+
y: 0
|
|
58
|
+
},
|
|
39
59
|
showTimer: 0
|
|
40
60
|
};
|
|
41
61
|
_refs = this.useRefs((ref) => ({
|
|
@@ -58,11 +78,34 @@ var JlTooltip = class extends JlElement {
|
|
|
58
78
|
onConnect() {
|
|
59
79
|
this.describeTrigger();
|
|
60
80
|
this.on(this._refs.slot, "slotchange", () => this.describeTrigger());
|
|
61
|
-
this.on(this, "pointerenter", () =>
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
this.on(this, "pointerenter", (event) => {
|
|
82
|
+
if (event.pointerType === TOUCH) return;
|
|
83
|
+
this.holdTitles();
|
|
84
|
+
this.scheduleShow();
|
|
85
|
+
});
|
|
86
|
+
this.on(this, "pointerleave", (event) => {
|
|
87
|
+
if (event.pointerType !== TOUCH) this.scheduleHide();
|
|
88
|
+
});
|
|
89
|
+
this.on(this, "pointerdown", (event) => {
|
|
90
|
+
if (event.pointerType === TOUCH) {
|
|
91
|
+
this.pressStart(event);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.hideBubble();
|
|
95
|
+
});
|
|
96
|
+
this.on(this, "pointermove", (event) => this.pressMove(event));
|
|
97
|
+
this.on(this, "pointerup", () => this.pressEnd());
|
|
98
|
+
this.on(this, "pointercancel", () => this.pressEnd());
|
|
99
|
+
this.on(this, "contextmenu", (event) => this.pressHeld(event));
|
|
100
|
+
this.addEventListener("click", (event) => this.swallowLongPress(event), {
|
|
101
|
+
capture: true,
|
|
102
|
+
signal: this.killSignal
|
|
103
|
+
});
|
|
64
104
|
this.on(this, "focusin", () => {
|
|
65
|
-
if (deepActiveElement(document)?.matches(":focus-visible") === true)
|
|
105
|
+
if (deepActiveElement(document)?.matches(":focus-visible") === true) {
|
|
106
|
+
this.holdTitles();
|
|
107
|
+
this.scheduleShow();
|
|
108
|
+
}
|
|
66
109
|
});
|
|
67
110
|
this.on(this, "focusout", () => this.close());
|
|
68
111
|
}
|
|
@@ -78,12 +121,93 @@ var JlTooltip = class extends JlElement {
|
|
|
78
121
|
this.whenRendered(() => this.close());
|
|
79
122
|
}
|
|
80
123
|
close() {
|
|
124
|
+
this.hideBubble();
|
|
125
|
+
this.restoreTitles();
|
|
126
|
+
}
|
|
127
|
+
/** A trigger that acts on a tap (a button, a link, a field), as opposed to a word or an icon. */
|
|
128
|
+
isInteractive() {
|
|
129
|
+
return accessibleTarget(this.trigger()).matches(INTERACTIVE);
|
|
130
|
+
}
|
|
131
|
+
pressStart(event) {
|
|
132
|
+
const press = this._state.press;
|
|
133
|
+
window.clearTimeout(press.timer);
|
|
134
|
+
press.opened = false;
|
|
135
|
+
if (!this.isInteractive()) {
|
|
136
|
+
if (this.isOpen()) {
|
|
137
|
+
this.close();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
this.open();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
press.down = true;
|
|
144
|
+
press.x = event.clientX;
|
|
145
|
+
press.y = event.clientY;
|
|
146
|
+
press.timer = window.setTimeout(() => this.openFromPress(), LONG_PRESS_MS);
|
|
147
|
+
}
|
|
148
|
+
pressMove(event) {
|
|
149
|
+
const press = this._state.press;
|
|
150
|
+
const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
|
151
|
+
if (press.down && moved > MOVE_TOLERANCE_PX) window.clearTimeout(press.timer);
|
|
152
|
+
}
|
|
153
|
+
/** The finger lifted, or the page took it to scroll: a bubble the press showed stays a moment, then goes. */
|
|
154
|
+
pressEnd() {
|
|
155
|
+
const press = this._state.press;
|
|
156
|
+
window.clearTimeout(press.timer);
|
|
157
|
+
press.down = false;
|
|
158
|
+
if (!press.opened) return;
|
|
159
|
+
window.clearTimeout(this._state.hideTimer);
|
|
160
|
+
this._state.hideTimer = window.setTimeout(() => this.close(), TOUCH_LINGER_MS);
|
|
161
|
+
}
|
|
162
|
+
/** The system's own long press: the bubble shows now, and the menu it would open does not, but over a link. */
|
|
163
|
+
pressHeld(event) {
|
|
164
|
+
if (!this._state.press.down || !this.isInteractive()) return;
|
|
165
|
+
this.openFromPress();
|
|
166
|
+
if (!accessibleTarget(this.trigger()).matches("a[href]")) event.preventDefault();
|
|
167
|
+
}
|
|
168
|
+
openFromPress() {
|
|
169
|
+
window.clearTimeout(this._state.press.timer);
|
|
170
|
+
this._state.press.opened = true;
|
|
171
|
+
this.open();
|
|
172
|
+
}
|
|
173
|
+
swallowLongPress(event) {
|
|
174
|
+
const press = this._state.press;
|
|
175
|
+
if (!press.opened) return;
|
|
176
|
+
press.opened = false;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
event.stopPropagation();
|
|
179
|
+
}
|
|
180
|
+
hideBubble() {
|
|
81
181
|
window.clearTimeout(this._state.showTimer);
|
|
82
182
|
window.clearTimeout(this._state.hideTimer);
|
|
83
183
|
this._state.following.abort();
|
|
84
184
|
const bubble = this._refs.bubble;
|
|
85
185
|
if (bubble.matches(":popover-open")) bubble.hidePopover();
|
|
86
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Sets aside the native `title` of the trigger and of its inner control: the bubble says it, and the browser's
|
|
189
|
+
* own tooltip would show over it a moment later. The control keeps its name for screen readers (a button's hint
|
|
190
|
+
* is its `aria-label` too).
|
|
191
|
+
*/
|
|
192
|
+
holdTitles() {
|
|
193
|
+
if (this.text.get() === "" || this._state.heldTitles.length > 0) return;
|
|
194
|
+
const trigger = this.trigger();
|
|
195
|
+
const elements = [.../* @__PURE__ */ new Set([trigger, accessibleTarget(trigger)])];
|
|
196
|
+
this._state.heldTitles = elements.flatMap((element) => {
|
|
197
|
+
const title = element.getAttribute(TITLE);
|
|
198
|
+
if (title === null) return [];
|
|
199
|
+
element.removeAttribute(TITLE);
|
|
200
|
+
return [{
|
|
201
|
+
element,
|
|
202
|
+
title
|
|
203
|
+
}];
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Gives the titles back, unless the trigger wrote new ones meanwhile. */
|
|
207
|
+
restoreTitles() {
|
|
208
|
+
for (const { element, title } of this._state.heldTitles) if (!element.hasAttribute(TITLE)) element.setAttribute(TITLE, title);
|
|
209
|
+
this._state.heldTitles = [];
|
|
210
|
+
}
|
|
87
211
|
trigger() {
|
|
88
212
|
return this._refs.slot.assignedElements({ flatten: true })[0] ?? this;
|
|
89
213
|
}
|
|
@@ -117,6 +241,9 @@ var JlTooltip = class extends JlElement {
|
|
|
117
241
|
passive: true,
|
|
118
242
|
signal: following.signal
|
|
119
243
|
});
|
|
244
|
+
document.addEventListener("pointerdown", (event) => {
|
|
245
|
+
if (!event.composedPath().includes(this)) this.close();
|
|
246
|
+
}, { signal: following.signal });
|
|
120
247
|
document.addEventListener("keydown", (event) => {
|
|
121
248
|
if (event.key === "Escape") this.close();
|
|
122
249
|
}, { signal: following.signal });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { JlElement } from '../base/jl-element';
|
|
2
|
-
import type { ConfirmOptions, ConfirmTone } from '../types/jl-confirm.types';
|
|
2
|
+
import type { ConfirmOptions, ConfirmTone, LeaveGuardOptions } from '../types/jl-confirm.types';
|
|
3
3
|
export declare const CONFIRM_TONES: readonly ConfirmTone[];
|
|
4
4
|
/**
|
|
5
5
|
* A yes-or-no question in a small modal dialog. `ask()` opens it and resolves `true` on confirm, `false` on cancel,
|
|
@@ -54,3 +54,9 @@ export declare class JlConfirm extends JlElement {
|
|
|
54
54
|
* ```
|
|
55
55
|
*/
|
|
56
56
|
export declare function confirmDialog(options: ConfirmOptions): Promise<boolean>;
|
|
57
|
+
/**
|
|
58
|
+
* Guards a form or an editor against leaving with changes not saved. Closing or reloading the tab asks the
|
|
59
|
+
* browser's own question; the function it returns asks in the page, for the app's router to call before it moves
|
|
60
|
+
* on: `if (!(await canLeave())) return;`. It resolves `true` at once when nothing would be lost.
|
|
61
|
+
*/
|
|
62
|
+
export declare function guardLeave(options: LeaveGuardOptions): () => Promise<boolean>;
|
|
@@ -129,4 +129,25 @@ function confirmDialog(options) {
|
|
|
129
129
|
document.body.append(element);
|
|
130
130
|
return element.ask().finally(() => element.remove());
|
|
131
131
|
}
|
|
132
|
-
|
|
132
|
+
var LEAVE_HEADING = "Leave without saving?";
|
|
133
|
+
var LEAVE_TEXT = "Your changes are lost.";
|
|
134
|
+
var LEAVE_LABEL = "Leave";
|
|
135
|
+
var STAY_LABEL = "Keep editing";
|
|
136
|
+
/**
|
|
137
|
+
* Guards a form or an editor against leaving with changes not saved. Closing or reloading the tab asks the
|
|
138
|
+
* browser's own question; the function it returns asks in the page, for the app's router to call before it moves
|
|
139
|
+
* on: `if (!(await canLeave())) return;`. It resolves `true` at once when nothing would be lost.
|
|
140
|
+
*/
|
|
141
|
+
function guardLeave(options) {
|
|
142
|
+
window.addEventListener("beforeunload", (event) => {
|
|
143
|
+
if (options.isDirty()) event.preventDefault();
|
|
144
|
+
}, { signal: options.signal });
|
|
145
|
+
return async () => !options.isDirty() || confirmDialog({
|
|
146
|
+
cancelLabel: options.stayLabel ?? STAY_LABEL,
|
|
147
|
+
confirmLabel: options.leaveLabel ?? LEAVE_LABEL,
|
|
148
|
+
heading: options.heading ?? LEAVE_HEADING,
|
|
149
|
+
text: options.text ?? LEAVE_TEXT,
|
|
150
|
+
tone: "danger"
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
export { CONFIRM_TONES, JlConfirm, confirmDialog, guardLeave };
|
|
@@ -24,7 +24,10 @@ export declare class JlToaster extends JlElement {
|
|
|
24
24
|
}>>;
|
|
25
25
|
private readonly _watched;
|
|
26
26
|
private readonly _refs;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Shows a notification. It stays 5 seconds unless `duration` says otherwise, 8 for an error or with an action;
|
|
29
|
+
* it has a close button unless told not to.
|
|
30
|
+
*/
|
|
28
31
|
notify(options: ToastOptions): ToastHandle;
|
|
29
32
|
templateHtml(): Node;
|
|
30
33
|
templateCss(): string;
|
|
@@ -16,8 +16,12 @@ var TOASTER_PLACEMENTS = [
|
|
|
16
16
|
var DEFAULT_MAX = 3;
|
|
17
17
|
var MAX_LIMIT = 20;
|
|
18
18
|
var DEFAULT_DURATION = 5e3;
|
|
19
|
+
var LONG_DURATION = 8e3;
|
|
20
|
+
var LONG_TONE = "danger";
|
|
19
21
|
var DEFAULT_LABEL = "Notifications";
|
|
20
22
|
var STACKED = "data-stacked";
|
|
23
|
+
/** How long a toast stays when its options do not say: longer for an error, or to give time to press its action. */
|
|
24
|
+
var defaultDuration = (options) => options.tone === LONG_TONE || (options.actionLabel ?? "") !== "" ? LONG_DURATION : DEFAULT_DURATION;
|
|
21
25
|
var OPEN = ":popover-open";
|
|
22
26
|
/**
|
|
23
27
|
* The corner of the window where notifications stack, newest nearest the edge, at most `max` at a time. It lives in
|
|
@@ -42,14 +46,17 @@ var JlToaster = class extends JlElement {
|
|
|
42
46
|
region: ref(".region"),
|
|
43
47
|
slot: ref("slot")
|
|
44
48
|
}));
|
|
45
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Shows a notification. It stays 5 seconds unless `duration` says otherwise, 8 for an error or with an action;
|
|
51
|
+
* it has a close button unless told not to.
|
|
52
|
+
*/
|
|
46
53
|
notify(options) {
|
|
47
54
|
const toast = /* @__PURE__ */ jsx(JlToast, {
|
|
48
55
|
heading: options.heading,
|
|
49
56
|
text: options.text,
|
|
50
57
|
tone: options.tone,
|
|
51
58
|
icon: options.icon,
|
|
52
|
-
duration: options.duration ??
|
|
59
|
+
duration: options.duration ?? defaultDuration(options),
|
|
53
60
|
closable: options.closable ?? true,
|
|
54
61
|
actionLabel: options.actionLabel
|
|
55
62
|
});
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// src/styles/control.css?raw
|
|
2
|
-
var control_default = "/* The look of the fields people type in: text fields, selects and areas share it. */\n.control {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n min-height: var(--height, var(--jl-control-md));\n padding: 0 12px;\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--jl-radius-sm);\n background: var(--jl-surface);\n color: var(--jl-text);\n font-size: var(--jl-font
|
|
2
|
+
var control_default = "/* The look of the fields people type in: text fields, selects and areas share it. */\n.control {\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n min-height: var(--height, var(--jl-control-md));\n padding: 0 12px;\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--jl-radius-sm);\n background: var(--jl-surface);\n color: var(--jl-text);\n font-size: var(--jl-control-font);\n line-height: 20px;\n transition:\n border-color var(--jl-fast) var(--jl-ease),\n box-shadow var(--jl-fast) var(--jl-ease);\n}\n\n.control:hover {\n border-color: color-mix(in srgb, var(--jl-line-strong) 60%, var(--jl-text) 18%);\n}\n\n.control:focus-within {\n border-color: var(--jl-accent);\n box-shadow: var(--jl-focus-ring);\n}\n\n:host([invalid]) .control {\n border-color: var(--jl-danger);\n}\n\n:host([invalid]) .control:focus-within {\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--jl-danger) 25%, transparent);\n}\n\n:host([disabled]) .control,\n:host(:disabled) .control {\n background: var(--jl-surface-2);\n color: var(--jl-muted);\n cursor: not-allowed;\n}\n\n:host([size=\"sm\"]) {\n --height: var(--jl-control-sm);\n}\n\n:host([size=\"lg\"]) {\n --height: var(--jl-control-lg);\n}\n\n/* The native element inside the frame: it takes the room and leaves the frame to draw the border. */\n.native {\n flex: 1;\n min-width: 0;\n height: calc(var(--height, var(--jl-control-md)) - 2px);\n padding: 0;\n border: none;\n outline: none;\n background: transparent;\n color: inherit;\n font: inherit;\n}\n\n.native::placeholder {\n color: var(--jl-faint);\n}\n\n.native:focus-visible {\n box-shadow: none;\n}\n\n.native:disabled {\n cursor: not-allowed;\n}\n\n.adornment {\n display: inline-flex;\n flex: none;\n align-items: center;\n color: var(--jl-faint);\n}\n\n/* A small button inside the frame: clear, reveal the password, step a number. */\n.action {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 24px;\n height: 24px;\n margin-right: -4px;\n padding: 0;\n border: none;\n border-radius: var(--jl-radius-xs);\n background: none;\n color: var(--jl-faint);\n cursor: pointer;\n}\n\n.action:hover {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n";
|
|
3
3
|
export { control_default as default };
|