@syware/jlib 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/all.js +10 -3
  2. package/dist/atoms/jl-badge.d.ts +12 -1
  3. package/dist/atoms/jl-badge.js +15 -1
  4. package/dist/atoms/jl-button.css_raw.js +1 -1
  5. package/dist/atoms/jl-button.d.ts +19 -0
  6. package/dist/atoms/jl-button.js +75 -23
  7. package/dist/atoms/jl-chip.css_raw.js +1 -1
  8. package/dist/atoms/jl-input.d.ts +5 -0
  9. package/dist/atoms/jl-input.js +2 -0
  10. package/dist/index.d.ts +12 -0
  11. package/dist/index.js +9 -2
  12. package/dist/lib/attributes.d.ts +5 -0
  13. package/dist/lib/attributes.js +13 -1
  14. package/dist/lib/time.d.ts +10 -0
  15. package/dist/lib/time.js +64 -0
  16. package/dist/lib/tokens.d.ts +2 -1
  17. package/dist/lib/tokens.js +13 -4
  18. package/dist/molecules/jl-account-button.css_raw.js +3 -0
  19. package/dist/molecules/jl-account-button.d.ts +46 -0
  20. package/dist/molecules/jl-account-button.js +97 -0
  21. package/dist/molecules/jl-alert.d.ts +10 -3
  22. package/dist/molecules/jl-alert.js +23 -6
  23. package/dist/molecules/jl-chip-group.css_raw.js +3 -0
  24. package/dist/molecules/jl-chip-group.d.ts +81 -0
  25. package/dist/molecules/jl-chip-group.js +259 -0
  26. package/dist/molecules/jl-load-state.css_raw.js +3 -0
  27. package/dist/molecules/jl-load-state.d.ts +61 -0
  28. package/dist/molecules/jl-load-state.js +200 -0
  29. package/dist/molecules/jl-search-button.css_raw.js +3 -0
  30. package/dist/molecules/jl-search-button.d.ts +34 -0
  31. package/dist/molecules/jl-search-button.js +80 -0
  32. package/dist/molecules/jl-tag-input.css_raw.js +1 -1
  33. package/dist/organisms/jl-confirm.d.ts +7 -1
  34. package/dist/organisms/jl-confirm.js +22 -1
  35. package/dist/organisms/jl-tab-bar.css_raw.js +3 -0
  36. package/dist/organisms/jl-tab-bar.d.ts +43 -0
  37. package/dist/organisms/jl-tab-bar.js +123 -0
  38. package/dist/organisms/jl-toaster.d.ts +4 -1
  39. package/dist/organisms/jl-toaster.js +9 -2
  40. package/dist/styles/control.css_raw.js +1 -1
  41. package/dist/styles/tokens.css_raw.js +1 -1
  42. package/dist/templates/jl-app-shell.css_raw.js +1 -1
  43. package/dist/templates/jl-app-shell.d.ts +8 -2
  44. package/dist/templates/jl-app-shell.js +32 -5
  45. package/dist/templates/jl-page.css_raw.js +3 -0
  46. package/dist/templates/jl-page.d.ts +49 -0
  47. package/dist/templates/jl-page.js +102 -0
  48. package/dist/tokens.css +22 -0
  49. package/dist/types/jl-badge.types.d.ts +11 -0
  50. package/dist/types/jl-chip-group.types.d.ts +10 -0
  51. package/dist/types/jl-confirm.types.d.ts +15 -0
  52. package/dist/types/jl-load-state.types.d.ts +10 -0
  53. package/dist/types/jl-page.types.d.ts +2 -0
  54. package/dist/types/jl-tab-bar.types.d.ts +11 -0
  55. package/dist/types/jl-toaster.types.d.ts +1 -1
  56. package/package.json +1 -1
@@ -0,0 +1,259 @@
1
+ import { BASE_STYLES } from "../styles/shared.js";
2
+ import { JlFormControl } from "../base/jl-form-control.js";
3
+ import { jsonOf, oneOf, stringList } from "../lib/attributes.js";
4
+ import { registerIcons } from "../lib/icons.js";
5
+ import { CONTROL_SIZES } from "../lib/options.js";
6
+ import { setOrRemove } from "../lib/dom.js";
7
+ import { JlIcon } from "../atoms/jl-icon.js";
8
+ import { rovingIndex } from "../lib/roving-index.js";
9
+ import jl_chip_group_default from "./jl-chip-group.css_raw.js";
10
+ import { css } from "@jadis/core";
11
+ import { Check } from "lucide";
12
+ import { jsx, jsxs } from "@jadis/core/jsx-runtime";
13
+ // src/molecules/jl-chip-group.tsx
14
+ registerIcons({ check: Check });
15
+ var ICON_SIZES = {
16
+ lg: 16,
17
+ md: 14,
18
+ sm: 12
19
+ };
20
+ var SELECTED_ICON = "check";
21
+ var CHIP_SELECTOR = ".chip";
22
+ var VALUE_MISSING = "Please select one of these options.";
23
+ /** The keys that move between the chips; with one choice, the choice moves too, as with native radios. */
24
+ var MOVES = /* @__PURE__ */ new Map([
25
+ ["ArrowDown", "next"],
26
+ ["ArrowLeft", "previous"],
27
+ ["ArrowRight", "next"],
28
+ ["ArrowUp", "previous"],
29
+ ["End", "last"],
30
+ ["Home", "first"]
31
+ ]);
32
+ /** A JSON attribute that should hold a list of options; anything else gives none. */
33
+ var parseOptions = (value) => {
34
+ const parsed = jsonOf([])(value);
35
+ return Array.isArray(parsed) ? parsed : [];
36
+ };
37
+ /**
38
+ * A row of chips to choose from: a status to filter by, a category, a reason. One choice by default, as radios
39
+ * (choosing the chosen chip again keeps it); `multiple` lets people turn each chip on and off. On a narrow screen
40
+ * the row scrolls sideways; `wrap` lets it go to the next line instead. `change` fires with the chosen values.
41
+ */
42
+ var JlChipGroup = class extends JlFormControl {
43
+ static selector = "jl-chip-group";
44
+ events = this.useEvents();
45
+ options = this.useChange([], () => this.renderOptions(), {
46
+ attribute: "options",
47
+ parse: parseOptions
48
+ });
49
+ /** The chosen values, in the options' order; with one choice, a list of at most one. */
50
+ value = this.useChange([], () => this.renderValue(), {
51
+ attribute: "value",
52
+ parse: stringList
53
+ });
54
+ /** Several chips can be chosen, each turned on and off on its own. */
55
+ multiple = this.useChange(false, (value) => {
56
+ this.reflect("multiple", value);
57
+ this.renderValue();
58
+ }, { attribute: "multiple" });
59
+ /** Goes to the next line when the chips do not fit, instead of scrolling sideways. */
60
+ wrap = this.useChange(false, (value) => this.reflect("wrap", value), { attribute: "wrap" });
61
+ size = this.useChange("md", (value) => {
62
+ this.reflect("size", value);
63
+ this.renderOptions();
64
+ }, {
65
+ attribute: "size",
66
+ parse: oneOf(CONTROL_SIZES, "md")
67
+ });
68
+ /** The group's name for screen readers, when no visible label names it. */
69
+ label = this.useChange("", (value) => setOrRemove(this._refs.group, "aria-label", value), { attribute: "label" });
70
+ /** A form refuses to submit while nothing is chosen. */
71
+ required = this.useChange(false, (value) => {
72
+ this.reflect("required", value);
73
+ this._refs.group.toggleAttribute("aria-required", value);
74
+ this.renderValidity();
75
+ }, { attribute: "required" });
76
+ disabled = this.useChange(false, (value) => {
77
+ this.reflect("disabled", value);
78
+ this.renderValue();
79
+ }, { attribute: "disabled" });
80
+ /** The chip that holds the Tab stop with several choices: the last one people reached. */
81
+ _state = {
82
+ disabledByForm: false,
83
+ stop: ""
84
+ };
85
+ _nameWatch = new MutationObserver(() => this.renderFormValue());
86
+ _refs = this.useRefs((ref) => ({ group: ref(".group") }));
87
+ /** Focuses the chip that holds the Tab stop. */
88
+ focus(options) {
89
+ this.chips().find((chip) => chip.tabIndex === 0)?.focus(options);
90
+ }
91
+ templateHtml() {
92
+ return /* @__PURE__ */ jsx("div", {
93
+ class: "group",
94
+ role: "radiogroup",
95
+ attrs: { part: "base" }
96
+ });
97
+ }
98
+ templateCss() {
99
+ return css`${BASE_STYLES}${jl_chip_group_default}`;
100
+ }
101
+ onConnect() {
102
+ this._nameWatch.observe(this, {
103
+ attributeFilter: ["name"],
104
+ attributes: true
105
+ });
106
+ this.renderFormValue();
107
+ this.on(this._refs.group, "click", (event) => {
108
+ const chip = event.target.closest(CHIP_SELECTOR);
109
+ if (chip !== null && !chip.disabled) this.press(chip);
110
+ });
111
+ this.on(this._refs.group, "keydown", (event) => this.onKeydown(event));
112
+ }
113
+ onDisconnect() {
114
+ this._nameWatch.disconnect();
115
+ }
116
+ resetValue() {
117
+ this.value.set(stringList(this.getAttribute("value")));
118
+ }
119
+ /** The browser calls it for a disabled fieldset and for the element's own `disabled`, even before it renders. */
120
+ setDisabledByForm(disabled) {
121
+ this._state.disabledByForm = disabled;
122
+ this.whenRendered(() => this.renderValue());
123
+ }
124
+ chips() {
125
+ return [...this._refs.group.querySelectorAll(CHIP_SELECTOR)];
126
+ }
127
+ /** What is chosen: with one choice, only the first value counts. */
128
+ chosen() {
129
+ const values = this.value.get();
130
+ return this.multiple.get() ? values : values.slice(0, 1);
131
+ }
132
+ isDisabled() {
133
+ return this.disabled.get() || this._state.disabledByForm;
134
+ }
135
+ /** One choice: the chip becomes it. Several: the chip turns on or off, the values keeping the options' order. */
136
+ press(chip) {
137
+ const value = chip.dataset.value ?? "";
138
+ this._state.stop = value;
139
+ const chosen = this.chosen();
140
+ if (!this.multiple.get()) {
141
+ if (chosen[0] === value) {
142
+ this.renderTabStop();
143
+ return;
144
+ }
145
+ this.commit([value]);
146
+ return;
147
+ }
148
+ const next = new Set(chosen);
149
+ if (next.has(value)) next.delete(value);
150
+ else next.add(value);
151
+ this.commit(this.options.get().flatMap((option) => next.has(option.value) ? [option.value] : []));
152
+ }
153
+ commit(values) {
154
+ this.value.set(values);
155
+ this.events.emit("change", values);
156
+ }
157
+ onKeydown(event) {
158
+ const move = MOVES.get(event.key);
159
+ const chips = this.chips();
160
+ const from = chips.indexOf(event.target);
161
+ if (move === void 0 || from === -1) return;
162
+ event.preventDefault();
163
+ const target = chips[rovingIndex(chips.map((chip) => chip.disabled), from, move)];
164
+ if (target === void 0) return;
165
+ target.focus();
166
+ if (this.multiple.get()) {
167
+ this._state.stop = target.dataset.value ?? "";
168
+ this.renderTabStop();
169
+ return;
170
+ }
171
+ this.press(target);
172
+ }
173
+ renderOptions() {
174
+ const iconSize = ICON_SIZES[this.size.get()];
175
+ this._refs.group.replaceChildren(...this.options.get().map((option) => this.chip(option, iconSize)));
176
+ this.renderValue();
177
+ }
178
+ chip(option, iconSize) {
179
+ const badge = option.badge === void 0 ? "" : String(option.badge);
180
+ return /* @__PURE__ */ jsxs("button", {
181
+ type: "button",
182
+ class: "chip",
183
+ tabIndex: -1,
184
+ "data-value": option.value,
185
+ "data-icon": option.icon,
186
+ "data-disabled": option.disabled === true ? "" : void 0,
187
+ children: [
188
+ /* @__PURE__ */ jsx(JlIcon, {
189
+ class: "icon",
190
+ size: iconSize,
191
+ hidden: true
192
+ }),
193
+ /* @__PURE__ */ jsx("span", {
194
+ class: "label",
195
+ children: option.label
196
+ }),
197
+ badge === "" ? "" : /* @__PURE__ */ jsx("span", {
198
+ class: "badge",
199
+ children: badge
200
+ })
201
+ ]
202
+ });
203
+ }
204
+ /** Radios with one choice, toggle buttons with several; each chosen chip shows its icon or a check. */
205
+ renderValue() {
206
+ const chosen = new Set(this.chosen());
207
+ const multiple = this.multiple.get();
208
+ const groupDisabled = this.isDisabled();
209
+ this._refs.group.setAttribute("role", multiple ? "group" : "radiogroup");
210
+ this._refs.group.toggleAttribute("aria-disabled", groupDisabled);
211
+ for (const chip of this.chips()) {
212
+ const selected = chosen.has(chip.dataset.value ?? "");
213
+ chip.disabled = groupDisabled || chip.dataset.disabled !== void 0;
214
+ setOrRemove(chip, "role", multiple ? "" : "radio");
215
+ setOrRemove(chip, "aria-checked", multiple ? "" : String(selected));
216
+ setOrRemove(chip, "aria-pressed", multiple ? String(selected) : "");
217
+ chip.classList.toggle("selected", selected);
218
+ const icon = chip.querySelector(".icon");
219
+ const name = chip.dataset.icon ?? (selected ? SELECTED_ICON : "");
220
+ icon?.name.set(name);
221
+ if (icon !== null) icon.hidden = name === "";
222
+ }
223
+ this.renderTabStop();
224
+ this.renderFormValue();
225
+ this.renderValidity();
226
+ }
227
+ /** Tab reaches one chip: the chosen one with one choice, the last one reached with several, else the first. */
228
+ renderTabStop() {
229
+ const enabled = this.chips().filter((chip) => !chip.disabled);
230
+ const chosen = this.chosen();
231
+ const stop = (this.multiple.get() ? [this._state.stop, ...chosen] : chosen).map((value) => enabled.find((chip) => chip.dataset.value === value)).find((chip) => chip !== void 0) ?? enabled[0];
232
+ for (const chip of this.chips()) chip.tabIndex = chip === stop ? 0 : -1;
233
+ }
234
+ /** One choice gives one entry; several give one entry each under the field's name, as checkboxes would. */
235
+ renderFormValue() {
236
+ const chosen = this.chosen();
237
+ if (!this.multiple.get()) {
238
+ this.internals.setFormValue(chosen[0] ?? null);
239
+ return;
240
+ }
241
+ const name = this.getAttribute("name") ?? "";
242
+ if (name === "") {
243
+ this.internals.setFormValue(null);
244
+ return;
245
+ }
246
+ const data = new FormData();
247
+ for (const value of chosen) data.append(name, value);
248
+ this.internals.setFormValue(data);
249
+ }
250
+ renderValidity() {
251
+ if (!(this.required.get() && this.chosen().length === 0)) {
252
+ this.internals.setValidity({});
253
+ return;
254
+ }
255
+ this.internals.setValidity({ valueMissing: true }, VALUE_MISSING, this.chips()[0] ?? this._refs.group);
256
+ }
257
+ };
258
+ JlChipGroup.register();
259
+ export { JlChipGroup };
@@ -0,0 +1,3 @@
1
+ // src/molecules/jl-load-state.css?raw
2
+ var jl_load_state_default = ":host {\n display: block;\n}\n\n.bones {\n display: grid;\n gap: 16px;\n padding: 16px;\n}\n\n.bones.lines {\n gap: 10px;\n}\n\n.bones.cards {\n grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));\n gap: 12px;\n padding: 0;\n}\n\n.bone-row {\n display: flex;\n align-items: center;\n gap: 12px;\n}\n\n.bone-lines {\n display: grid;\n flex: 1;\n gap: 6px;\n}\n\n.bone-card {\n display: grid;\n gap: 8px;\n padding: 12px;\n border: 1px solid var(--jl-line);\n border-radius: var(--jl-radius-lg);\n}\n";
3
+ export { jl_load_state_default as default };
@@ -0,0 +1,61 @@
1
+ import { JlElement } from '../base/jl-element';
2
+ import type { LoadSkeleton, LoadState, LoadTask } from '../types/jl-load-state.types';
3
+ export declare const LOAD_STATES: readonly LoadState[];
4
+ export declare const LOAD_SKELETONS: readonly LoadSkeleton[];
5
+ /**
6
+ * What a list or a page shows while its data comes: nothing for a moment, then a skeleton shaped like it; then the
7
+ * data (the default slot), what to say when there is none (slot `empty`, a `jl-empty`), or, when it failed, what
8
+ * failed and Try again. `load()` fetches, draws and moves between the states; a later `load()` keeps what is shown
9
+ * until the new data is there, so live updates do not flash. Or set `state` yourself.
10
+ */
11
+ export declare class JlLoadState extends JlElement {
12
+ static readonly selector = "jl-load-state";
13
+ readonly events: Readonly<import("@jadis/core").UseEventsHandler<{
14
+ retry: undefined;
15
+ }>>;
16
+ readonly state: Readonly<Readonly<{
17
+ get(): LoadState;
18
+ set(setter: ((prevState: LoadState) => LoadState) | LoadState): void;
19
+ }>>;
20
+ /** The shape of the skeleton; slot `loading` takes one of the page's own instead. */
21
+ readonly skeleton: Readonly<Readonly<{
22
+ get(): LoadSkeleton;
23
+ set(setter: ((prevState: LoadSkeleton) => LoadSkeleton) | LoadSkeleton): void;
24
+ }>>;
25
+ /** How many rows, cards or lines the skeleton has. */
26
+ readonly count: Readonly<Readonly<{
27
+ get(): number;
28
+ set(setter: number | ((prevState: number) => number)): void;
29
+ }>>;
30
+ /** Milliseconds before the skeleton shows. */
31
+ readonly delay: Readonly<Readonly<{
32
+ get(): number;
33
+ set(setter: number | ((prevState: number) => number)): void;
34
+ }>>;
35
+ readonly errorHeading: Readonly<Readonly<{
36
+ get(): string;
37
+ set(setter: string | ((prevState: string) => string)): void;
38
+ }>>;
39
+ readonly errorText: Readonly<Readonly<{
40
+ get(): string;
41
+ set(setter: string | ((prevState: string) => string)): void;
42
+ }>>;
43
+ readonly retryLabel: Readonly<Readonly<{
44
+ get(): string;
45
+ set(setter: string | ((prevState: string) => string)): void;
46
+ }>>;
47
+ private readonly _work;
48
+ private readonly _refs;
49
+ /**
50
+ * Fetches the data and draws it. The first time, and after a failure, the skeleton shows while it comes; later
51
+ * (a refresh), what is shown stays until the new data is there, and stays too if it fails. Resolves to whether it
52
+ * worked: a refresh that fails says so only here, for the page to tell people.
53
+ */
54
+ load<Value>(task: LoadTask<Value>): Promise<boolean>;
55
+ templateHtml(): Node;
56
+ templateCss(): string;
57
+ onConnect(): void;
58
+ onDisconnect(): void;
59
+ private renderState;
60
+ private renderSkeleton;
61
+ }
@@ -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-tag-input.css?raw
2
- var jl_tag_input_default = ":host {\n display: block;\n /* As a grid or flex item, the field may be narrower than its text field would like. */\n min-width: 0;\n --chip-height: calc(var(--height, var(--jl-control-md)) - 12px);\n}\n\n.control {\n flex-wrap: wrap;\n gap: 4px;\n padding: 4px 6px;\n cursor: text;\n}\n\n/* The chips are items of the frame's own flow, so the field follows the last one on its line. */\n.tags {\n display: contents;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.tag {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n max-width: 100%;\n height: var(--chip-height);\n padding: 0 3px 0 8px;\n border: 1px solid var(--jl-line);\n border-radius: var(--jl-radius-xs);\n background: var(--jl-surface-2);\n color: var(--jl-text);\n font-size: var(--jl-font-sm);\n line-height: 1;\n cursor: default;\n}\n\n:host([size=\"sm\"]) .tag {\n padding-left: 6px;\n font-size: var(--jl-font-xs);\n}\n\n.tag.flash {\n animation: flash 600ms var(--jl-ease);\n}\n\n.tag-text {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tag-remove {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 4px;\n background: none;\n color: var(--jl-faint);\n cursor: pointer;\n}\n\n.tag-remove:hover,\n.tag-remove:focus-visible {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n.native {\n flex: 1 0 80px;\n height: calc(var(--height, var(--jl-control-md)) - 10px);\n padding-left: 6px;\n}\n\n:host([disabled]) .tag {\n padding-right: 8px;\n color: var(--jl-muted);\n}\n\n.popup {\n min-width: 180px;\n padding: 4px;\n}\n\n.listbox {\n position: relative;\n max-height: 240px;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n list-style: none;\n}\n\n.option {\n padding: 6px 10px;\n border-radius: var(--jl-radius-xs);\n color: var(--jl-text);\n font-size: var(--jl-font-md);\n line-height: 1.4;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n}\n\n.option.active,\n.option:hover {\n background: var(--jl-surface-3);\n}\n\nmark {\n background: none;\n color: var(--jl-accent);\n font-weight: 700;\n}\n\n@keyframes flash {\n 30% {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent-strong);\n }\n}\n";
2
+ var jl_tag_input_default = ":host {\n display: block;\n /* As a grid or flex item, the field may be narrower than its text field would like. */\n min-width: 0;\n --chip-height: calc(var(--height, var(--jl-control-md)) - 12px);\n}\n\n.control {\n flex-wrap: wrap;\n gap: 4px;\n padding: 4px 6px;\n cursor: text;\n}\n\n/* The chips are items of the frame's own flow, so the field follows the last one on its line. */\n.tags {\n display: contents;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.tag {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n max-width: 100%;\n height: var(--chip-height);\n padding: 0 3px 0 8px;\n border: 1px solid var(--jl-line);\n border-radius: var(--jl-radius-xs);\n background: var(--jl-surface-2);\n color: var(--jl-text);\n font-size: var(--jl-font-sm);\n line-height: 1;\n cursor: default;\n}\n\n:host([size=\"sm\"]) .tag {\n padding-left: 6px;\n font-size: var(--jl-font-xs);\n}\n\n.tag.flash {\n animation: flash 600ms var(--jl-ease);\n}\n\n/* A line box taller than the tag's own line-height of 1: the accents of É, Â, Ö stay inside what overflow clips. */\n.tag-text {\n overflow: hidden;\n line-height: 1.4;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tag-remove {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 4px;\n background: none;\n color: var(--jl-faint);\n cursor: pointer;\n}\n\n.tag-remove:hover,\n.tag-remove:focus-visible {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n.native {\n flex: 1 0 80px;\n height: calc(var(--height, var(--jl-control-md)) - 10px);\n padding-left: 6px;\n}\n\n:host([disabled]) .tag {\n padding-right: 8px;\n color: var(--jl-muted);\n}\n\n.popup {\n min-width: 180px;\n padding: 4px;\n}\n\n.listbox {\n position: relative;\n max-height: 240px;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n list-style: none;\n}\n\n.option {\n padding: 6px 10px;\n border-radius: var(--jl-radius-xs);\n color: var(--jl-text);\n font-size: var(--jl-font-md);\n line-height: 1.4;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n}\n\n.option.active,\n.option:hover {\n background: var(--jl-surface-3);\n}\n\nmark {\n background: none;\n color: var(--jl-accent);\n font-weight: 700;\n}\n\n@keyframes flash {\n 30% {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent-strong);\n }\n}\n";
3
3
  export { jl_tag_input_default as default };
@@ -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>;