@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.
Files changed (35) hide show
  1. package/dist/all.js +8 -3
  2. package/dist/atoms/jl-badge.d.ts +12 -1
  3. package/dist/atoms/jl-badge.js +15 -1
  4. package/dist/index.d.ts +8 -0
  5. package/dist/index.js +7 -2
  6. package/dist/lib/time.d.ts +10 -0
  7. package/dist/lib/time.js +64 -0
  8. package/dist/molecules/jl-account-button.css_raw.js +3 -0
  9. package/dist/molecules/jl-account-button.d.ts +46 -0
  10. package/dist/molecules/jl-account-button.js +97 -0
  11. package/dist/molecules/jl-load-state.css_raw.js +3 -0
  12. package/dist/molecules/jl-load-state.d.ts +61 -0
  13. package/dist/molecules/jl-load-state.js +200 -0
  14. package/dist/molecules/jl-search-button.css_raw.js +3 -0
  15. package/dist/molecules/jl-search-button.d.ts +34 -0
  16. package/dist/molecules/jl-search-button.js +80 -0
  17. package/dist/molecules/jl-tooltip.css_raw.js +1 -1
  18. package/dist/molecules/jl-tooltip.d.ts +26 -1
  19. package/dist/molecules/jl-tooltip.js +132 -5
  20. package/dist/organisms/jl-confirm.d.ts +7 -1
  21. package/dist/organisms/jl-confirm.js +22 -1
  22. package/dist/organisms/jl-toaster.d.ts +4 -1
  23. package/dist/organisms/jl-toaster.js +9 -2
  24. package/dist/styles/control.css_raw.js +1 -1
  25. package/dist/styles/tokens.css_raw.js +1 -1
  26. package/dist/templates/jl-page.css_raw.js +3 -0
  27. package/dist/templates/jl-page.d.ts +49 -0
  28. package/dist/templates/jl-page.js +102 -0
  29. package/dist/tokens.css +22 -0
  30. package/dist/types/jl-badge.types.d.ts +11 -0
  31. package/dist/types/jl-confirm.types.d.ts +15 -0
  32. package/dist/types/jl-load-state.types.d.ts +10 -0
  33. package/dist/types/jl-page.types.d.ts +2 -0
  34. package/dist/types/jl-toaster.types.d.ts +1 -1
  35. 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", () => this.scheduleShow());
62
- this.on(this, "pointerleave", () => this.scheduleHide());
63
- this.on(this, "pointerdown", () => this.close());
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) this.scheduleShow();
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
- export { CONFIRM_TONES, JlConfirm, confirmDialog };
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
- /** Shows a notification; `duration` is 5 seconds unless given, and it has a close button unless told not to. */
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
- /** Shows a notification; `duration` is 5 seconds unless given, and it has a close button unless told not to. */
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 ?? DEFAULT_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-md);\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";
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 };