@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
@@ -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 };
@@ -0,0 +1,3 @@
1
+ // src/organisms/jl-tab-bar.css?raw
2
+ var jl_tab_bar_default = ":host {\n display: block;\n --height: 60px;\n}\n\nnav {\n display: grid;\n grid-auto-columns: minmax(0, 1fr);\n grid-auto-flow: column;\n min-height: var(--height);\n /* Clear of the home indicator on phones without a button. */\n padding-bottom: env(safe-area-inset-bottom);\n border-top: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n/* The items and the \"more\" button share the bar's columns. */\n.items {\n display: contents;\n}\n\n.item {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 2px;\n min-width: 0;\n padding: 6px 2px;\n border: none;\n background: none;\n color: var(--jl-muted);\n font-size: 11px;\n font-weight: 600;\n line-height: 1.4;\n text-decoration: none;\n cursor: pointer;\n transition: color var(--jl-fast) var(--jl-ease);\n}\n\n.item:hover {\n color: var(--jl-text);\n}\n\n.item[aria-current=\"page\"] {\n color: var(--jl-accent);\n}\n\n/* The ring goes round the icon's pill: round the whole column, it would touch the bar's edges. */\n.item:focus-visible {\n box-shadow: none;\n}\n\n.icon {\n position: relative;\n display: inline-grid;\n place-items: center;\n width: 52px;\n height: 28px;\n border-radius: var(--jl-radius-pill);\n transition: background var(--jl-fast) var(--jl-ease);\n}\n\n.item:focus-visible .icon {\n box-shadow: var(--jl-focus-ring);\n}\n\n/* The current item is told by a shape too, not by its colour alone. */\n.item[aria-current=\"page\"] .icon {\n background: var(--jl-accent-soft);\n}\n\n.label {\n max-width: 100%;\n padding: 0 2px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.badge {\n position: absolute;\n top: -4px;\n left: calc(50% + 4px);\n min-width: 18px;\n height: 18px;\n padding: 0 5px;\n border: 2px solid var(--jl-surface);\n border-radius: var(--jl-radius-pill);\n background: var(--jl-accent);\n color: var(--jl-on-accent);\n font-size: 10px;\n font-weight: 700;\n line-height: 14px;\n text-align: center;\n}\n";
3
+ export { jl_tab_bar_default as default };
@@ -0,0 +1,43 @@
1
+ import { JlElement } from '../base/jl-element';
2
+ import type { TabBarItem } from '../types/jl-tab-bar.types';
3
+ /**
4
+ * The bar along the bottom of a phone, under the thumb: the three to five main places of an app, an icon over a
5
+ * label, one tap away. `value` is the current item. Items with an `href` are links; the others are buttons; both
6
+ * fire `select`. `more` adds a last button for everything else, which fires `more`: in a `jl-app-shell`, it opens
7
+ * the navigation drawer.
8
+ */
9
+ export declare class JlTabBar extends JlElement {
10
+ static readonly selector = "jl-tab-bar";
11
+ readonly events: Readonly<import("@jadis/core").UseEventsHandler<{
12
+ select: string;
13
+ more: undefined;
14
+ }>>;
15
+ readonly items: Readonly<Readonly<{
16
+ get(): TabBarItem[];
17
+ set(setter: TabBarItem[] | ((prevState: TabBarItem[]) => TabBarItem[])): void;
18
+ }>>;
19
+ /** The id of the current item. */
20
+ readonly value: Readonly<Readonly<{
21
+ get(): string;
22
+ set(setter: string | ((prevState: string) => string)): void;
23
+ }>>;
24
+ /** The label of a last button for the other places (`More`); empty shows none. */
25
+ readonly more: Readonly<Readonly<{
26
+ get(): string;
27
+ set(setter: string | ((prevState: string) => string)): void;
28
+ }>>;
29
+ /** The name of the navigation for screen readers. */
30
+ readonly label: Readonly<Readonly<{
31
+ get(): string;
32
+ set(setter: string | ((prevState: string) => string)): void;
33
+ }>>;
34
+ private readonly _refs;
35
+ templateHtml(): Node;
36
+ templateCss(): string;
37
+ onConnect(): void;
38
+ private onClick;
39
+ private render;
40
+ private renderItem;
41
+ /** The current item says so: `aria-current="page"`, drawn in the accent. */
42
+ private renderCurrent;
43
+ }
@@ -0,0 +1,123 @@
1
+ import { BASE_STYLES } from "../styles/shared.js";
2
+ import { JlElement } from "../base/jl-element.js";
3
+ import { jsonOf } from "../lib/attributes.js";
4
+ import { registerIcons } from "../lib/icons.js";
5
+ import { setOrRemove } from "../lib/dom.js";
6
+ import { JlIcon } from "../atoms/jl-icon.js";
7
+ import jl_tab_bar_default from "./jl-tab-bar.css_raw.js";
8
+ import { css } from "@jadis/core";
9
+ import { Ellipsis } from "lucide";
10
+ import { Fragment, jsx, jsxs } from "@jadis/core/jsx-runtime";
11
+ // src/organisms/jl-tab-bar.tsx
12
+ registerIcons({ ellipsis: Ellipsis });
13
+ var ICON_SIZE = 22;
14
+ var DEFAULT_LABEL = "Shortcuts";
15
+ var MAX_COUNT = 99;
16
+ var ITEM_SELECTOR = ".item";
17
+ /** A JSON attribute that should hold a list of items; anything else gives none. */
18
+ var parseItems = (value) => {
19
+ const parsed = jsonOf([])(value);
20
+ return Array.isArray(parsed) ? parsed : [];
21
+ };
22
+ /** What the badge shows: counts are capped so they fit over the icon. */
23
+ var badgeText = (badge) => {
24
+ if (typeof badge === "number") return badge > MAX_COUNT ? `${MAX_COUNT}+` : String(badge);
25
+ return badge ?? "";
26
+ };
27
+ /**
28
+ * The bar along the bottom of a phone, under the thumb: the three to five main places of an app, an icon over a
29
+ * label, one tap away. `value` is the current item. Items with an `href` are links; the others are buttons; both
30
+ * fire `select`. `more` adds a last button for everything else, which fires `more`: in a `jl-app-shell`, it opens
31
+ * the navigation drawer.
32
+ */
33
+ var JlTabBar = class extends JlElement {
34
+ static selector = "jl-tab-bar";
35
+ events = this.useEvents();
36
+ items = this.useChange([], () => this.render(), {
37
+ attribute: "items",
38
+ parse: parseItems
39
+ });
40
+ /** The id of the current item. */
41
+ value = this.useChange("", (value) => this.renderCurrent(value), { attribute: "value" });
42
+ /** The label of a last button for the other places (`More`); empty shows none. */
43
+ more = this.useChange("", (value) => {
44
+ this._refs.moreLabel.textContent = value;
45
+ this._refs.more.hidden = value === "";
46
+ }, { attribute: "more" });
47
+ /** The name of the navigation for screen readers. */
48
+ label = this.useChange(DEFAULT_LABEL, (value) => this._refs.nav.setAttribute("aria-label", value === "" ? DEFAULT_LABEL : value), { attribute: "label" });
49
+ _refs = this.useRefs((ref) => ({
50
+ items: ref(".items"),
51
+ more: ref(".more"),
52
+ moreLabel: ref(".more .label"),
53
+ nav: ref("nav")
54
+ }));
55
+ templateHtml() {
56
+ return /* @__PURE__ */ jsxs("nav", {
57
+ attrs: { part: "base" },
58
+ children: [/* @__PURE__ */ jsx("div", { class: "items" }), /* @__PURE__ */ jsxs("button", {
59
+ class: "item more",
60
+ type: "button",
61
+ hidden: true,
62
+ children: [/* @__PURE__ */ jsx("span", {
63
+ class: "icon",
64
+ children: /* @__PURE__ */ jsx(JlIcon, {
65
+ name: "ellipsis",
66
+ size: ICON_SIZE
67
+ })
68
+ }), /* @__PURE__ */ jsx("span", { class: "label" })]
69
+ })]
70
+ });
71
+ }
72
+ templateCss() {
73
+ return css`${BASE_STYLES}${jl_tab_bar_default}`;
74
+ }
75
+ onConnect() {
76
+ this.on(this._refs.items, "click", (event) => this.onClick(event));
77
+ this.on(this._refs.more, "click", () => this.events.emit("more"));
78
+ }
79
+ onClick(event) {
80
+ const id = event.target.closest(ITEM_SELECTOR)?.dataset.id;
81
+ if (id === void 0) return;
82
+ this.value.set(id);
83
+ this.events.emit("select", id);
84
+ }
85
+ render() {
86
+ this._refs.items.replaceChildren(...this.items.get().map((item) => this.renderItem(item)));
87
+ this.renderCurrent(this.value.get());
88
+ }
89
+ renderItem(item) {
90
+ const badge = badgeText(item.badge);
91
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
92
+ class: "icon",
93
+ children: [/* @__PURE__ */ jsx(JlIcon, {
94
+ name: item.icon,
95
+ size: ICON_SIZE
96
+ }), badge === "" ? null : /* @__PURE__ */ jsx("span", {
97
+ class: "badge",
98
+ children: badge
99
+ })]
100
+ }), /* @__PURE__ */ jsx("span", {
101
+ class: "label",
102
+ children: item.label
103
+ })] });
104
+ if (item.href === void 0) return /* @__PURE__ */ jsx("button", {
105
+ class: "item",
106
+ type: "button",
107
+ "data-id": item.id,
108
+ children: content
109
+ });
110
+ return /* @__PURE__ */ jsx("a", {
111
+ class: "item",
112
+ href: item.href,
113
+ "data-id": item.id,
114
+ children: content
115
+ });
116
+ }
117
+ /** The current item says so: `aria-current="page"`, drawn in the accent. */
118
+ renderCurrent(value) {
119
+ for (const item of this._refs.items.querySelectorAll(ITEM_SELECTOR)) setOrRemove(item, "aria-current", item.dataset.id === value ? "page" : "");
120
+ }
121
+ };
122
+ JlTabBar.register();
123
+ export { JlTabBar };
@@ -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 };
@@ -1,3 +1,3 @@
1
1
  // src/styles/tokens.css?raw
2
- var tokens_default = "/*\n * The design tokens, as swai and swcode have them: ink and white, flat surfaces separated by lines, and one blue\n * that marks what is live, selected or waiting for a person. Custom properties cross shadow roots, so every\n * component reads them. They sit in the `jlib` layer: any unlayered rule of the page overrides them.\n */\n@layer jlib {\n :root,\n [data-theme=\"light\"] {\n color-scheme: light;\n --jl-font-sans: \"Manrope Variable\", \"Manrope\", system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --jl-font-mono: \"JetBrains Mono Variable\", \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n\n --jl-font-xs: 12px;\n --jl-font-sm: 13px;\n --jl-font-md: 14px;\n --jl-font-lg: 16px;\n --jl-font-xl: 20px;\n --jl-font-2xl: 28px;\n\n --jl-text: #0b0b0c;\n --jl-text-2: #3a3f47;\n /* Secondary text: 6.2:1 on white. */\n --jl-muted: #5b616b;\n /* Icons and decorations only: 3.2:1 on white is enough for them, not for text. */\n --jl-faint: #8a9099;\n\n --jl-bg: #ffffff;\n --jl-surface: #ffffff;\n --jl-surface-2: #f7f8f9;\n --jl-surface-3: #eef0f2;\n --jl-code-bg: #f0f2f4;\n --jl-line: #e6e8eb;\n --jl-line-soft: #eef0f2;\n --jl-line-strong: #d5d9de;\n\n /* The SyWare logo's blue: marks, focus rings, progress. 3.8:1 on white: enough for shapes, never for text. */\n --jl-brand: #0e88d3;\n /* The same hue, deep enough to carry text: buttons, links, the selection. 5.2:1 on white. */\n --jl-accent: #0c72b0;\n --jl-accent-strong: #095a8b;\n --jl-accent-soft: #e9f4fb;\n --jl-accent-line: #b7dbf2;\n --jl-on-accent: #ffffff;\n\n --jl-ok: #17784a;\n --jl-ok-soft: #ecf6f0;\n --jl-warn: #9a5b00;\n --jl-warn-soft: #fcf4e6;\n --jl-danger: #b42318;\n --jl-danger-soft: #fdf0ef;\n --jl-on-danger: #ffffff;\n --jl-info: #0c72b0;\n --jl-info-soft: #e9f4fb;\n\n /* Code: the accent for keywords, quiet colours from the same family for the rest. */\n --jl-code-keyword: #0c72b0;\n --jl-code-tag: #095a8b;\n --jl-code-attribute: #5b48c8;\n --jl-code-string: #17784a;\n --jl-code-number: #9a5b00;\n --jl-code-function: #3d5a80;\n --jl-code-property: #127a72;\n --jl-code-comment: #8a9099;\n\n --jl-radius-xs: 6px;\n --jl-radius-sm: 8px;\n --jl-radius: 10px;\n --jl-radius-lg: 12px;\n --jl-radius-xl: 20px;\n --jl-radius-pill: 999px;\n\n /* Spacing: a 4px grid, for the layouts pages build around the components. */\n --jl-space-1: 4px;\n --jl-space-2: 8px;\n --jl-space-3: 12px;\n --jl-space-4: 16px;\n --jl-space-5: 20px;\n --jl-space-6: 24px;\n --jl-space-8: 32px;\n --jl-space-10: 40px;\n --jl-space-12: 48px;\n --jl-space-16: 64px;\n\n --jl-control-sm: 30px;\n --jl-control-md: 36px;\n --jl-control-lg: 44px;\n\n /* Flat: lines separate; only what floats (menus, dialogs, toasts) casts a shadow. */\n --jl-shadow-sm: 0 1px 2px rgb(11 11 12 / 0.05);\n --jl-shadow: 0 6px 16px -6px rgb(11 11 12 / 0.12);\n --jl-shadow-lg: 0 18px 44px -14px rgb(11 11 12 / 0.22);\n --jl-focus-ring: 0 0 0 3px color-mix(in srgb, var(--jl-brand) 35%, transparent);\n --jl-scrim: rgb(11 11 12 / 0.38);\n\n --jl-ease: cubic-bezier(0.2, 0.7, 0.2, 1);\n --jl-fast: 120ms;\n --jl-slow: 240ms;\n }\n\n @media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {\n color-scheme: dark;\n --jl-text: #eceef1;\n --jl-text-2: #c3c8cf;\n --jl-muted: #959ca6;\n --jl-faint: #6b727c;\n --jl-bg: #0f1113;\n --jl-surface: #0f1113;\n --jl-surface-2: #16191c;\n --jl-surface-3: #1d2125;\n --jl-code-bg: #1d2125;\n --jl-line: #262a2f;\n --jl-line-soft: #1f2327;\n --jl-line-strong: #343a41;\n --jl-brand: #1a9be0;\n --jl-accent: #3ea6e8;\n --jl-accent-strong: #6cc0f2;\n --jl-accent-soft: #0f2a3b;\n --jl-accent-line: #1e4a66;\n --jl-on-accent: #04131c;\n --jl-ok: #4cc38a;\n --jl-ok-soft: #10261c;\n --jl-warn: #e5a54b;\n --jl-warn-soft: #2a2012;\n --jl-danger: #f0786c;\n --jl-danger-soft: #3a1a17;\n --jl-on-danger: #1c0604;\n --jl-info: #3ea6e8;\n --jl-info-soft: #0f2a3b;\n --jl-code-keyword: #3ea6e8;\n --jl-code-tag: #6cc0f2;\n --jl-code-attribute: #a99bf0;\n --jl-code-string: #4cc38a;\n --jl-code-number: #e5a54b;\n --jl-code-function: #8fb0d6;\n --jl-code-property: #4fc1b5;\n --jl-code-comment: #6b727c;\n --jl-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);\n --jl-shadow: 0 6px 18px -6px rgb(0 0 0 / 0.6);\n --jl-shadow-lg: 0 18px 44px -12px rgb(0 0 0 / 0.7);\n --jl-scrim: rgb(0 0 0 / 0.55);\n }\n }\n\n /* Dark: the same blue, lifted to read on a dark ground, with dark text on the buttons. */\n [data-theme=\"dark\"] {\n color-scheme: dark;\n --jl-text: #eceef1;\n --jl-text-2: #c3c8cf;\n --jl-muted: #959ca6;\n --jl-faint: #6b727c;\n --jl-bg: #0f1113;\n --jl-surface: #0f1113;\n --jl-surface-2: #16191c;\n --jl-surface-3: #1d2125;\n --jl-code-bg: #1d2125;\n --jl-line: #262a2f;\n --jl-line-soft: #1f2327;\n --jl-line-strong: #343a41;\n --jl-brand: #1a9be0;\n --jl-accent: #3ea6e8;\n --jl-accent-strong: #6cc0f2;\n --jl-accent-soft: #0f2a3b;\n --jl-accent-line: #1e4a66;\n --jl-on-accent: #04131c;\n --jl-ok: #4cc38a;\n --jl-ok-soft: #10261c;\n --jl-warn: #e5a54b;\n --jl-warn-soft: #2a2012;\n --jl-danger: #f0786c;\n --jl-danger-soft: #3a1a17;\n --jl-on-danger: #1c0604;\n --jl-info: #3ea6e8;\n --jl-info-soft: #0f2a3b;\n --jl-code-keyword: #3ea6e8;\n --jl-code-tag: #6cc0f2;\n --jl-code-attribute: #a99bf0;\n --jl-code-string: #4cc38a;\n --jl-code-number: #e5a54b;\n --jl-code-function: #8fb0d6;\n --jl-code-property: #4fc1b5;\n --jl-code-comment: #6b727c;\n --jl-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);\n --jl-shadow: 0 6px 18px -6px rgb(0 0 0 / 0.6);\n --jl-shadow-lg: 0 18px 44px -12px rgb(0 0 0 / 0.7);\n --jl-scrim: rgb(0 0 0 / 0.55);\n }\n}\n";
2
+ var tokens_default = "/*\n * The design tokens, as swai and swcode have them: ink and white, flat surfaces separated by lines, and one blue\n * that marks what is live, selected or waiting for a person. Custom properties cross shadow roots, so every\n * component reads them. They sit in the `jlib` layer: any unlayered rule of the page overrides them.\n */\n@layer jlib {\n :root,\n [data-theme=\"light\"] {\n color-scheme: light;\n --jl-font-sans: \"Manrope Variable\", \"Manrope\", system-ui, -apple-system, \"Segoe UI\", sans-serif;\n --jl-font-mono: \"JetBrains Mono Variable\", \"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, monospace;\n\n --jl-font-xs: 12px;\n --jl-font-sm: 13px;\n --jl-font-md: 14px;\n --jl-font-lg: 16px;\n --jl-font-xl: 20px;\n --jl-font-2xl: 28px;\n\n --jl-text: #0b0b0c;\n --jl-text-2: #3a3f47;\n /* Secondary text: 6.2:1 on white. */\n --jl-muted: #5b616b;\n /* Icons and decorations only: 3.2:1 on white is enough for them, not for text. */\n --jl-faint: #8a9099;\n\n --jl-bg: #ffffff;\n --jl-surface: #ffffff;\n --jl-surface-2: #f7f8f9;\n --jl-surface-3: #eef0f2;\n --jl-code-bg: #f0f2f4;\n --jl-line: #e6e8eb;\n --jl-line-soft: #eef0f2;\n --jl-line-strong: #d5d9de;\n\n /* The SyWare logo's blue: marks, focus rings, progress. 3.8:1 on white: enough for shapes, never for text. */\n --jl-brand: #0e88d3;\n /* The same hue, deep enough to carry text: buttons, links, the selection. 5.2:1 on white. */\n --jl-accent: #0c72b0;\n --jl-accent-strong: #095a8b;\n --jl-accent-soft: #e9f4fb;\n --jl-accent-line: #b7dbf2;\n --jl-on-accent: #ffffff;\n\n --jl-ok: #17784a;\n --jl-ok-soft: #ecf6f0;\n --jl-warn: #9a5b00;\n --jl-warn-soft: #fcf4e6;\n --jl-danger: #b42318;\n --jl-danger-soft: #fdf0ef;\n --jl-on-danger: #ffffff;\n --jl-info: #0c72b0;\n --jl-info-soft: #e9f4fb;\n\n /* Code: the accent for keywords, quiet colours from the same family for the rest. */\n --jl-code-keyword: #0c72b0;\n --jl-code-tag: #095a8b;\n --jl-code-attribute: #5b48c8;\n --jl-code-string: #17784a;\n --jl-code-number: #9a5b00;\n --jl-code-function: #3d5a80;\n --jl-code-property: #127a72;\n --jl-code-comment: #8a9099;\n\n --jl-radius-xs: 6px;\n --jl-radius-sm: 8px;\n --jl-radius: 10px;\n --jl-radius-lg: 12px;\n --jl-radius-xl: 20px;\n --jl-radius-pill: 999px;\n\n /* Spacing: a 4px grid, for the layouts pages build around the components. */\n --jl-space-1: 4px;\n --jl-space-2: 8px;\n --jl-space-3: 12px;\n --jl-space-4: 16px;\n --jl-space-5: 20px;\n --jl-space-6: 24px;\n --jl-space-8: 32px;\n --jl-space-10: 40px;\n --jl-space-12: 48px;\n --jl-space-16: 64px;\n\n --jl-control-sm: 30px;\n --jl-control-md: 36px;\n --jl-control-lg: 44px;\n /* The text people type in fields. */\n --jl-control-font: var(--jl-font-md);\n\n /* The widest a page gets: a form, most pages, and the tables and grids that need the room. */\n --jl-page-form: 720px;\n --jl-page-standard: 1120px;\n --jl-page-wide: 1400px;\n\n /* Flat: lines separate; only what floats (menus, dialogs, toasts) casts a shadow. */\n --jl-shadow-sm: 0 1px 2px rgb(11 11 12 / 0.05);\n --jl-shadow: 0 6px 16px -6px rgb(11 11 12 / 0.12);\n --jl-shadow-lg: 0 18px 44px -14px rgb(11 11 12 / 0.22);\n --jl-focus-ring: 0 0 0 3px color-mix(in srgb, var(--jl-brand) 35%, transparent);\n --jl-scrim: rgb(11 11 12 / 0.38);\n\n --jl-ease: cubic-bezier(0.2, 0.7, 0.2, 1);\n --jl-fast: 120ms;\n --jl-slow: 240ms;\n }\n\n @media (prefers-color-scheme: dark) {\n :root:not([data-theme=\"light\"]) {\n color-scheme: dark;\n --jl-text: #eceef1;\n --jl-text-2: #c3c8cf;\n --jl-muted: #959ca6;\n --jl-faint: #6b727c;\n --jl-bg: #0f1113;\n --jl-surface: #0f1113;\n --jl-surface-2: #16191c;\n --jl-surface-3: #1d2125;\n --jl-code-bg: #1d2125;\n --jl-line: #262a2f;\n --jl-line-soft: #1f2327;\n --jl-line-strong: #343a41;\n --jl-brand: #1a9be0;\n --jl-accent: #3ea6e8;\n --jl-accent-strong: #6cc0f2;\n --jl-accent-soft: #0f2a3b;\n --jl-accent-line: #1e4a66;\n --jl-on-accent: #04131c;\n --jl-ok: #4cc38a;\n --jl-ok-soft: #10261c;\n --jl-warn: #e5a54b;\n --jl-warn-soft: #2a2012;\n --jl-danger: #f0786c;\n --jl-danger-soft: #3a1a17;\n --jl-on-danger: #1c0604;\n --jl-info: #3ea6e8;\n --jl-info-soft: #0f2a3b;\n --jl-code-keyword: #3ea6e8;\n --jl-code-tag: #6cc0f2;\n --jl-code-attribute: #a99bf0;\n --jl-code-string: #4cc38a;\n --jl-code-number: #e5a54b;\n --jl-code-function: #8fb0d6;\n --jl-code-property: #4fc1b5;\n --jl-code-comment: #6b727c;\n --jl-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);\n --jl-shadow: 0 6px 18px -6px rgb(0 0 0 / 0.6);\n --jl-shadow-lg: 0 18px 44px -12px rgb(0 0 0 / 0.7);\n --jl-scrim: rgb(0 0 0 / 0.55);\n }\n }\n\n /* Dark: the same blue, lifted to read on a dark ground, with dark text on the buttons. */\n [data-theme=\"dark\"] {\n color-scheme: dark;\n --jl-text: #eceef1;\n --jl-text-2: #c3c8cf;\n --jl-muted: #959ca6;\n --jl-faint: #6b727c;\n --jl-bg: #0f1113;\n --jl-surface: #0f1113;\n --jl-surface-2: #16191c;\n --jl-surface-3: #1d2125;\n --jl-code-bg: #1d2125;\n --jl-line: #262a2f;\n --jl-line-soft: #1f2327;\n --jl-line-strong: #343a41;\n --jl-brand: #1a9be0;\n --jl-accent: #3ea6e8;\n --jl-accent-strong: #6cc0f2;\n --jl-accent-soft: #0f2a3b;\n --jl-accent-line: #1e4a66;\n --jl-on-accent: #04131c;\n --jl-ok: #4cc38a;\n --jl-ok-soft: #10261c;\n --jl-warn: #e5a54b;\n --jl-warn-soft: #2a2012;\n --jl-danger: #f0786c;\n --jl-danger-soft: #3a1a17;\n --jl-on-danger: #1c0604;\n --jl-info: #3ea6e8;\n --jl-info-soft: #0f2a3b;\n --jl-code-keyword: #3ea6e8;\n --jl-code-tag: #6cc0f2;\n --jl-code-attribute: #a99bf0;\n --jl-code-string: #4cc38a;\n --jl-code-number: #e5a54b;\n --jl-code-function: #8fb0d6;\n --jl-code-property: #4fc1b5;\n --jl-code-comment: #6b727c;\n --jl-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);\n --jl-shadow: 0 6px 18px -6px rgb(0 0 0 / 0.6);\n --jl-shadow-lg: 0 18px 44px -12px rgb(0 0 0 / 0.7);\n --jl-scrim: rgb(0 0 0 / 0.55);\n }\n\n /*\n * Touch screens: controls a finger can hit (44px for most), and field text phones do not zoom into when it takes\n * the focus (16px). Themed parts of a page re-declare the sizes, so they take these too.\n */\n @media (pointer: coarse) {\n :root,\n [data-theme=\"light\"],\n [data-theme=\"dark\"] {\n --jl-control-sm: 36px;\n --jl-control-md: 44px;\n --jl-control-lg: 48px;\n --jl-control-font: 16px;\n }\n }\n}\n";
3
3
  export { tokens_default as default };
@@ -1,3 +1,3 @@
1
1
  // src/templates/jl-app-shell.css?raw
2
- var jl_app_shell_default = ":host {\n display: block;\n height: 100%;\n min-height: 0;\n container: jl-app-shell / inline-size;\n --header-height: 56px;\n --drawer-width: min(300px, 86%);\n}\n\n.shell {\n position: relative;\n display: grid;\n grid-template:\n \"top top top\" auto\n \"nav main aside\" minmax(0, 1fr)\n / auto minmax(0, 1fr) auto;\n height: 100%;\n overflow: hidden;\n background: var(--jl-bg);\n color: var(--jl-text);\n}\n\n.top {\n grid-area: top;\n display: flex;\n align-items: center;\n gap: 8px;\n min-height: var(--header-height);\n padding: 0 16px;\n border-bottom: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n:host(:not([has-header])) .top {\n display: none;\n}\n\n::slotted([slot=\"header\"]) {\n flex: 1;\n min-width: 0;\n}\n\n.menu {\n display: none;\n flex: none;\n align-items: center;\n justify-content: center;\n width: var(--jl-control-md);\n height: var(--jl-control-md);\n margin-left: -8px;\n padding: 0;\n border: none;\n border-radius: var(--jl-radius-sm);\n background: none;\n color: var(--jl-text-2);\n cursor: pointer;\n}\n\n.menu:hover {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n.nav {\n grid-area: nav;\n min-height: 0;\n overflow-y: auto;\n border-right: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n.nav:focus-visible {\n box-shadow: none;\n}\n\n:host(:not([has-nav])) .nav {\n display: none;\n}\n\n.main {\n grid-area: main;\n min-width: 0;\n min-height: 0;\n overflow: auto;\n}\n\n.aside {\n grid-area: aside;\n min-height: 0;\n overflow-y: auto;\n border-left: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n:host(:not([has-aside])) .aside {\n display: none;\n}\n\n.scrim {\n display: none;\n}\n\n/* Narrow: the sidebar slides over the content as a drawer, the menu button opens it, the panel steps aside. */\n@container jl-app-shell (width < 960px) {\n .shell {\n grid-template:\n \"top\" auto\n \"main\" minmax(0, 1fr)\n / minmax(0, 1fr);\n }\n\n :host([has-nav]) .top {\n display: flex;\n }\n\n :host([has-nav]) .menu {\n display: inline-flex;\n }\n\n .nav {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n max-width: var(--drawer-width);\n box-shadow: var(--jl-shadow-lg);\n transform: translateX(-100%);\n visibility: hidden;\n transition:\n transform var(--jl-slow) var(--jl-ease),\n visibility 0s linear var(--jl-slow);\n }\n\n :host([nav-open]) .nav {\n transform: none;\n visibility: visible;\n transition: transform var(--jl-slow) var(--jl-ease);\n }\n\n .scrim {\n position: absolute;\n inset: 0;\n z-index: 1;\n display: block;\n background: var(--jl-scrim);\n opacity: 0;\n pointer-events: none;\n transition: opacity var(--jl-slow) var(--jl-ease);\n }\n\n :host([nav-open]) .scrim {\n opacity: 1;\n pointer-events: auto;\n }\n\n .aside {\n display: none;\n }\n}\n";
2
+ var jl_app_shell_default = ":host {\n display: block;\n height: 100%;\n min-height: 0;\n container: jl-app-shell / inline-size;\n --header-height: 56px;\n --drawer-width: min(300px, 86%);\n}\n\n.shell {\n position: relative;\n display: grid;\n grid-template:\n \"top top top\" auto\n \"nav main aside\" minmax(0, 1fr)\n / auto minmax(0, 1fr) auto;\n height: 100%;\n overflow: hidden;\n background: var(--jl-bg);\n color: var(--jl-text);\n}\n\n.top {\n grid-area: top;\n display: flex;\n align-items: center;\n gap: 8px;\n min-height: var(--header-height);\n padding: 0 16px;\n border-bottom: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n:host(:not([has-header])) .top {\n display: none;\n}\n\n::slotted([slot=\"header\"]) {\n flex: 1;\n min-width: 0;\n}\n\n.menu {\n display: none;\n flex: none;\n align-items: center;\n justify-content: center;\n width: var(--jl-control-md);\n height: var(--jl-control-md);\n margin-left: -8px;\n padding: 0;\n border: none;\n border-radius: var(--jl-radius-sm);\n background: none;\n color: var(--jl-text-2);\n cursor: pointer;\n}\n\n.menu:hover {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n.nav {\n grid-area: nav;\n min-height: 0;\n overflow-y: auto;\n border-right: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n.nav:focus-visible {\n box-shadow: none;\n}\n\n:host(:not([has-nav])) .nav {\n display: none;\n}\n\n/* Its own stacking context: what the page lifts with z-index (a sticky bar) stays under the drawer and its scrim. */\n.main {\n grid-area: main;\n min-width: 0;\n min-height: 0;\n overflow: auto;\n isolation: isolate;\n}\n\n.aside {\n grid-area: aside;\n min-height: 0;\n overflow-y: auto;\n border-left: 1px solid var(--jl-line);\n background: var(--jl-surface);\n}\n\n:host(:not([has-aside])) .aside {\n display: none;\n}\n\n/* The bottom bar is for narrow screens: wide ones have the sidebar. */\n.tabs {\n display: none;\n}\n\n.scrim {\n display: none;\n}\n\n/* Narrow: the sidebar slides over the content as a drawer, the menu button opens it, the panel steps aside. */\n@container jl-app-shell (width < 960px) {\n .shell {\n grid-template:\n \"top\" auto\n \"main\" minmax(0, 1fr)\n \"tabs\" auto\n / minmax(0, 1fr);\n }\n\n :host([has-tabs]) .tabs {\n display: block;\n grid-area: tabs;\n }\n\n :host([has-nav]) .top {\n display: flex;\n }\n\n :host([has-nav]) .menu {\n display: inline-flex;\n }\n\n .nav {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n max-width: var(--drawer-width);\n box-shadow: var(--jl-shadow-lg);\n transform: translateX(-100%);\n visibility: hidden;\n transition:\n transform var(--jl-slow) var(--jl-ease),\n visibility 0s linear var(--jl-slow);\n }\n\n :host([nav-open]) .nav {\n transform: none;\n visibility: visible;\n transition: transform var(--jl-slow) var(--jl-ease);\n }\n\n .scrim {\n position: absolute;\n inset: 0;\n z-index: 1;\n display: block;\n background: var(--jl-scrim);\n opacity: 0;\n pointer-events: none;\n transition: opacity var(--jl-slow) var(--jl-ease);\n }\n\n :host([nav-open]) .scrim {\n opacity: 1;\n pointer-events: auto;\n }\n\n .aside {\n display: none;\n }\n}\n";
3
3
  export { jl_app_shell_default as default };
@@ -2,7 +2,8 @@ import { JlElement } from '../base/jl-element';
2
2
  /**
3
3
  * The frame of an app: a top bar (`header`), a sidebar (`nav`), the main area (the default slot), which scrolls
4
4
  * on its own, and an optional panel on the right (`aside`). Narrower than 960 pixels, the sidebar becomes a drawer
5
- * over the content, opened by a menu button in the top bar (or `nav-open`, or `toggleNav()`), and the panel hides.
5
+ * over the content, opened by a menu button in the top bar (or `nav-open`, or `toggleNav()`), the panel hides, and
6
+ * a bar along the bottom shows (`tabs`, such as a `jl-tab-bar`, whose `more` opens the drawer).
6
7
  * It takes the height of its container: give that one a height (`100dvh` for a whole page).
7
8
  */
8
9
  export declare class JlAppShell extends JlElement {
@@ -27,11 +28,16 @@ export declare class JlAppShell extends JlElement {
27
28
  /** Whether the shell is narrow: the menu button shows only then. */
28
29
  private isNarrow;
29
30
  private onMenu;
31
+ /** The tab bar's "more": the other places are in the drawer. */
32
+ private onMore;
30
33
  private onKey;
31
34
  /** The drawer as people open and close it: says so. */
32
35
  private setNavOpen;
33
36
  private renderNavOpen;
34
37
  private renderInert;
35
- /** What was slotted: empty areas take no room, and a `select` from the nav (a side nav's) closes the drawer. */
38
+ /**
39
+ * What was slotted: empty areas take no room, a `select` from the nav (a side nav's) closes the drawer, and a
40
+ * `more` from the tabs (a tab bar's) opens it.
41
+ */
36
42
  private renderSlots;
37
43
  }
@@ -15,7 +15,8 @@ var followsLink = (event) => event.composedPath().some((node) => node instanceof
15
15
  /**
16
16
  * The frame of an app: a top bar (`header`), a sidebar (`nav`), the main area (the default slot), which scrolls
17
17
  * on its own, and an optional panel on the right (`aside`). Narrower than 960 pixels, the sidebar becomes a drawer
18
- * over the content, opened by a menu button in the top bar (or `nav-open`, or `toggleNav()`), and the panel hides.
18
+ * over the content, opened by a menu button in the top bar (or `nav-open`, or `toggleNav()`), the panel hides, and
19
+ * a bar along the bottom shows (`tabs`, such as a `jl-tab-bar`, whose `more` opens the drawer).
19
20
  * It takes the height of its container: give that one a height (`100dvh` for a whole page).
20
21
  */
21
22
  var JlAppShell = class extends JlElement {
@@ -23,7 +24,10 @@ var JlAppShell = class extends JlElement {
23
24
  events = this.useEvents();
24
25
  /** Whether the drawer is open, on narrow screens; wide ones always show the sidebar. */
25
26
  navOpen = this.useChange(false, (value) => this.renderNavOpen(value), { attribute: "nav-open" });
26
- _state = { navListeners: new AbortController() };
27
+ _state = {
28
+ navListeners: new AbortController(),
29
+ tabsListeners: new AbortController()
30
+ };
27
31
  _resize = new ResizeObserver(() => this.renderInert());
28
32
  _refs = this.useRefs((ref) => ({
29
33
  aside: ref(".aside"),
@@ -33,7 +37,9 @@ var JlAppShell = class extends JlElement {
33
37
  menu: ref(".menu"),
34
38
  nav: ref(".nav"),
35
39
  navSlot: ref("slot[name=\"nav\"]"),
36
- scrim: ref(".scrim")
40
+ scrim: ref(".scrim"),
41
+ tabs: ref(".tabs"),
42
+ tabsSlot: ref("slot[name=\"tabs\"]")
37
43
  }));
38
44
  /** Opens or closes the drawer; `force` sets the state instead of flipping it. */
39
45
  toggleNav(force) {
@@ -76,6 +82,11 @@ var JlAppShell = class extends JlElement {
76
82
  attrs: { part: "aside" },
77
83
  children: /* @__PURE__ */ jsx("slot", { name: "aside" })
78
84
  }),
85
+ /* @__PURE__ */ jsx("div", {
86
+ class: "tabs",
87
+ attrs: { part: "tabs" },
88
+ children: /* @__PURE__ */ jsx("slot", { name: "tabs" })
89
+ }),
79
90
  /* @__PURE__ */ jsx("div", { class: "scrim" })
80
91
  ]
81
92
  });
@@ -95,12 +106,14 @@ var JlAppShell = class extends JlElement {
95
106
  for (const slot of [
96
107
  this._refs.headerSlot,
97
108
  this._refs.navSlot,
98
- this._refs.asideSlot
109
+ this._refs.asideSlot,
110
+ this._refs.tabsSlot
99
111
  ]) this.on(slot, "slotchange", () => this.renderSlots());
100
112
  }
101
113
  onDisconnect() {
102
114
  this._resize.disconnect();
103
115
  this._state.navListeners.abort();
116
+ this._state.tabsListeners.abort();
104
117
  }
105
118
  /** Whether the shell is narrow: the menu button shows only then. */
106
119
  isNarrow() {
@@ -111,6 +124,11 @@ var JlAppShell = class extends JlElement {
111
124
  this.setNavOpen(opening);
112
125
  if (opening) this._refs.nav.focus();
113
126
  }
127
+ /** The tab bar's "more": the other places are in the drawer. */
128
+ onMore() {
129
+ this.setNavOpen(true);
130
+ this._refs.nav.focus();
131
+ }
114
132
  onKey(event) {
115
133
  if (event.key !== "Escape" || !this.navOpen.get() || !this.isNarrow()) return;
116
134
  event.preventDefault();
@@ -132,11 +150,20 @@ var JlAppShell = class extends JlElement {
132
150
  const covered = this.navOpen.get() && this.isNarrow();
133
151
  this._refs.main.inert = covered;
134
152
  this._refs.aside.inert = covered;
153
+ this._refs.tabs.inert = covered;
135
154
  }
136
- /** What was slotted: empty areas take no room, and a `select` from the nav (a side nav's) closes the drawer. */
155
+ /**
156
+ * What was slotted: empty areas take no room, a `select` from the nav (a side nav's) closes the drawer, and a
157
+ * `more` from the tabs (a tab bar's) opens it.
158
+ */
137
159
  renderSlots() {
138
160
  this.reflect("has-header", this._refs.headerSlot.assignedElements().length > 0);
139
161
  this.reflect("has-aside", this._refs.asideSlot.assignedElements().length > 0);
162
+ const tabElements = this._refs.tabsSlot.assignedElements();
163
+ this.reflect("has-tabs", tabElements.length > 0);
164
+ this._state.tabsListeners.abort();
165
+ this._state.tabsListeners = new AbortController();
166
+ for (const element of tabElements) element.addEventListener("more", () => this.onMore(), { signal: this._state.tabsListeners.signal });
140
167
  const navElements = this._refs.navSlot.assignedElements();
141
168
  this.reflect("has-nav", navElements.length > 0);
142
169
  this._state.navListeners.abort();
@@ -0,0 +1,3 @@
1
+ // src/templates/jl-page.css?raw
2
+ var jl_page_default = ":host {\n display: block;\n container-type: inline-size;\n --page-width: var(--jl-page-standard);\n}\n\n:host([width=\"form\"]) {\n --page-width: var(--jl-page-form);\n}\n\n:host([width=\"wide\"]) {\n --page-width: var(--jl-page-wide);\n}\n\n:host([width=\"full\"]) {\n --page-width: none;\n}\n\n.page {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: var(--jl-space-6);\n max-width: var(--page-width);\n margin: 0 auto;\n padding: var(--jl-space-8) var(--jl-space-10) var(--jl-space-12);\n}\n\n/* A grid's items grow to their content otherwise: a row of chips would widen the page. */\n::slotted(*) {\n min-width: 0;\n}\n\n.back {\n margin-left: -8px;\n}\n\n@container (max-width: 720px) {\n .page {\n padding: var(--jl-space-6) var(--jl-space-4) var(--jl-space-12);\n }\n}\n";
3
+ export { jl_page_default as default };
@@ -0,0 +1,49 @@
1
+ import { JlElement } from '../base/jl-element';
2
+ import type { PageWidth } from '../types/jl-page.types';
3
+ export declare const PAGE_WIDTHS: readonly PageWidth[];
4
+ /**
5
+ * A page of an app: its header (a back link named after the page it leads to, the heading, a sentence on what the
6
+ * page is for or the facts that identify its subject, the status, the actions), then the content, centred at one of
7
+ * four widths. With `app-name`, it also names the browser tab after its heading: "Order #3 · swstock".
8
+ */
9
+ export declare class JlPage extends JlElement {
10
+ static readonly selector = "jl-page";
11
+ readonly events: Readonly<import("@jadis/core").UseEventsHandler<{
12
+ back: undefined;
13
+ }>>;
14
+ readonly heading: Readonly<Readonly<{
15
+ get(): string;
16
+ set(setter: string | ((prevState: string) => string)): void;
17
+ }>>;
18
+ readonly description: Readonly<Readonly<{
19
+ get(): string;
20
+ set(setter: string | ((prevState: string) => string)): void;
21
+ }>>;
22
+ /** `form` for one column of fields, `standard` for most pages, `wide` for tables and grids, `full` for the rest. */
23
+ readonly width: Readonly<Readonly<{
24
+ get(): PageWidth;
25
+ set(setter: ((prevState: PageWidth) => PageWidth) | PageWidth): void;
26
+ }>>;
27
+ /** Where the back link leads; no back link without it. */
28
+ readonly back: Readonly<Readonly<{
29
+ get(): string;
30
+ set(setter: string | ((prevState: string) => string)): void;
31
+ }>>;
32
+ /** The name of the page the back link leads to: "Orders". */
33
+ readonly backLabel: Readonly<Readonly<{
34
+ get(): string;
35
+ set(setter: string | ((prevState: string) => string)): void;
36
+ }>>;
37
+ /** The app's name, after the heading in the browser tab. Without it, the page leaves the tab's title alone. */
38
+ readonly appName: Readonly<Readonly<{
39
+ get(): string;
40
+ set(setter: string | ((prevState: string) => string)): void;
41
+ }>>;
42
+ private readonly _refs;
43
+ templateHtml(): Node;
44
+ templateCss(): string;
45
+ onConnect(): void;
46
+ /** The back link stays out of the header's breadcrumb slot without an address, so the header takes no room for it. */
47
+ private renderBack;
48
+ private renderTitle;
49
+ }
@@ -0,0 +1,102 @@
1
+ import { BASE_STYLES } from "../styles/shared.js";
2
+ import { JlElement } from "../base/jl-element.js";
3
+ import { oneOf } from "../lib/attributes.js";
4
+ import { define } from "../lib/define.js";
5
+ import { registerIcons } from "../lib/icons.js";
6
+ import { setOrRemove } from "../lib/dom.js";
7
+ import { JlButton } from "../atoms/jl-button.js";
8
+ import { JlPageHeader } from "../organisms/jl-page-header.js";
9
+ import jl_page_default from "./jl-page.css_raw.js";
10
+ import { css } from "@jadis/core";
11
+ import { ArrowLeft } from "lucide";
12
+ import { jsx, jsxs } from "@jadis/core/jsx-runtime";
13
+ // src/templates/jl-page.tsx
14
+ registerIcons({ "arrow-left": ArrowLeft });
15
+ define(JlPageHeader);
16
+ var PAGE_WIDTHS = [
17
+ "standard",
18
+ "form",
19
+ "wide",
20
+ "full"
21
+ ];
22
+ var BREADCRUMB_SLOT = "breadcrumb";
23
+ var DEFAULT_BACK_LABEL = "Back";
24
+ var TITLE_SEPARATOR = " · ";
25
+ /**
26
+ * A page of an app: its header (a back link named after the page it leads to, the heading, a sentence on what the
27
+ * page is for or the facts that identify its subject, the status, the actions), then the content, centred at one of
28
+ * four widths. With `app-name`, it also names the browser tab after its heading: "Order #3 · swstock".
29
+ */
30
+ var JlPage = class extends JlElement {
31
+ static selector = "jl-page";
32
+ events = this.useEvents();
33
+ heading = this.useChange("", (value) => {
34
+ this._refs.header.heading.set(value);
35
+ this.renderTitle();
36
+ }, { attribute: "heading" });
37
+ description = this.useChange("", (value) => this._refs.header.description.set(value), { attribute: "description" });
38
+ /** `form` for one column of fields, `standard` for most pages, `wide` for tables and grids, `full` for the rest. */
39
+ width = this.useChange("standard", (value) => this.reflect("width", value), {
40
+ attribute: "width",
41
+ parse: oneOf(PAGE_WIDTHS, "standard")
42
+ });
43
+ /** Where the back link leads; no back link without it. */
44
+ back = this.useChange("", () => this.renderBack(), { attribute: "back" });
45
+ /** The name of the page the back link leads to: "Orders". */
46
+ backLabel = this.useChange("", () => this.renderBack(), { attribute: "back-label" });
47
+ /** The app's name, after the heading in the browser tab. Without it, the page leaves the tab's title alone. */
48
+ appName = this.useChange("", () => this.renderTitle(), { attribute: "app-name" });
49
+ _refs = this.useRefs((ref) => ({
50
+ back: ref(".back"),
51
+ header: ref("jl-page-header")
52
+ }));
53
+ templateHtml() {
54
+ return /* @__PURE__ */ jsxs("div", {
55
+ class: "page",
56
+ attrs: { part: "base" },
57
+ children: [/* @__PURE__ */ jsxs("jl-page-header", { children: [
58
+ /* @__PURE__ */ jsx(JlButton, {
59
+ class: "back",
60
+ variant: "ghost",
61
+ size: "sm",
62
+ icon: "arrow-left"
63
+ }),
64
+ /* @__PURE__ */ jsx("slot", {
65
+ name: "breadcrumb",
66
+ slot: "breadcrumb"
67
+ }),
68
+ /* @__PURE__ */ jsx("slot", {
69
+ name: "actions",
70
+ slot: "actions"
71
+ }),
72
+ /* @__PURE__ */ jsx("slot", {
73
+ name: "meta",
74
+ slot: "meta"
75
+ })
76
+ ] }), /* @__PURE__ */ jsx("slot", {})]
77
+ });
78
+ }
79
+ templateCss() {
80
+ return css`${BASE_STYLES}${jl_page_default}`;
81
+ }
82
+ onConnect() {
83
+ this.renderTitle();
84
+ this._refs.back.events.register("press", () => this.events.emit("back"));
85
+ }
86
+ /** The back link stays out of the header's breadcrumb slot without an address, so the header takes no room for it. */
87
+ renderBack() {
88
+ const back = this._refs.back;
89
+ const href = this.back.get();
90
+ back.href.set(href);
91
+ back.label.set(this.backLabel.get() || DEFAULT_BACK_LABEL);
92
+ setOrRemove(back, "slot", href === "" ? "" : BREADCRUMB_SLOT);
93
+ }
94
+ renderTitle() {
95
+ const app = this.appName.get();
96
+ if (app === "" || !this.isConnected) return;
97
+ const heading = this.heading.get();
98
+ document.title = heading === "" ? app : `${heading}${TITLE_SEPARATOR}${app}`;
99
+ }
100
+ };
101
+ JlPage.register();
102
+ export { JlPage, PAGE_WIDTHS };
package/dist/tokens.css CHANGED
@@ -84,6 +84,13 @@
84
84
  --jl-control-sm: 30px;
85
85
  --jl-control-md: 36px;
86
86
  --jl-control-lg: 44px;
87
+ /* The text people type in fields. */
88
+ --jl-control-font: var(--jl-font-md);
89
+
90
+ /* The widest a page gets: a form, most pages, and the tables and grids that need the room. */
91
+ --jl-page-form: 720px;
92
+ --jl-page-standard: 1120px;
93
+ --jl-page-wide: 1400px;
87
94
 
88
95
  /* Flat: lines separate; only what floats (menus, dialogs, toasts) casts a shadow. */
89
96
  --jl-shadow-sm: 0 1px 2px rgb(11 11 12 / 0.05);
@@ -185,4 +192,19 @@
185
192
  --jl-shadow-lg: 0 18px 44px -12px rgb(0 0 0 / 0.7);
186
193
  --jl-scrim: rgb(0 0 0 / 0.55);
187
194
  }
195
+
196
+ /*
197
+ * Touch screens: controls a finger can hit (44px for most), and field text phones do not zoom into when it takes
198
+ * the focus (16px). Themed parts of a page re-declare the sizes, so they take these too.
199
+ */
200
+ @media (pointer: coarse) {
201
+ :root,
202
+ [data-theme="light"],
203
+ [data-theme="dark"] {
204
+ --jl-control-sm: 36px;
205
+ --jl-control-md: 44px;
206
+ --jl-control-lg: 48px;
207
+ --jl-control-font: 16px;
208
+ }
209
+ }
188
210
  }
@@ -0,0 +1,11 @@
1
+ import type { Tone } from './options.types';
2
+ /**
3
+ * How one state of something shows as a badge: its word, its tone, its icon, and a sentence on what it means. One
4
+ * exhaustive map per kind of thing (`Record<OrderStatus, StatusLook>`) keeps every view saying a state the same way.
5
+ */
6
+ export type StatusLook = {
7
+ label: string;
8
+ tone: Tone;
9
+ icon?: string;
10
+ hint?: string;
11
+ };