@syware/jlib 0.2.0 → 0.4.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 (50) hide show
  1. package/README.md +1 -0
  2. package/dist/all.js +5 -3
  3. package/dist/atoms/jl-button.css_raw.js +1 -1
  4. package/dist/atoms/jl-button.d.ts +19 -0
  5. package/dist/atoms/jl-button.js +75 -23
  6. package/dist/atoms/jl-chip.css_raw.js +1 -1
  7. package/dist/atoms/jl-input.d.ts +6 -0
  8. package/dist/atoms/jl-input.js +4 -0
  9. package/dist/atoms/jl-select.d.ts +6 -0
  10. package/dist/atoms/jl-select.js +18 -1
  11. package/dist/atoms/jl-switch.d.ts +12 -1
  12. package/dist/atoms/jl-switch.js +11 -1
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.js +4 -2
  15. package/dist/lib/attributes.d.ts +5 -0
  16. package/dist/lib/attributes.js +13 -1
  17. package/dist/lib/flow.d.ts +10 -1
  18. package/dist/lib/flow.js +26 -9
  19. package/dist/lib/tokens.d.ts +2 -1
  20. package/dist/lib/tokens.js +13 -4
  21. package/dist/molecules/jl-accordion-item.css_raw.js +1 -1
  22. package/dist/molecules/jl-accordion-item.d.ts +6 -1
  23. package/dist/molecules/jl-accordion-item.js +21 -6
  24. package/dist/molecules/jl-alert.d.ts +10 -3
  25. package/dist/molecules/jl-alert.js +23 -6
  26. package/dist/molecules/jl-chip-group.css_raw.js +3 -0
  27. package/dist/molecules/jl-chip-group.d.ts +81 -0
  28. package/dist/molecules/jl-chip-group.js +259 -0
  29. package/dist/molecules/jl-search.d.ts +3 -1
  30. package/dist/molecules/jl-search.js +8 -6
  31. package/dist/molecules/jl-segmented.css_raw.js +1 -1
  32. package/dist/molecules/jl-segmented.js +14 -7
  33. package/dist/molecules/jl-stat.css_raw.js +1 -1
  34. package/dist/molecules/jl-stat.d.ts +6 -0
  35. package/dist/molecules/jl-stat.js +6 -0
  36. package/dist/molecules/jl-tag-input.css_raw.js +1 -1
  37. package/dist/organisms/jl-flow.css_raw.js +1 -1
  38. package/dist/organisms/jl-flow.d.ts +28 -5
  39. package/dist/organisms/jl-flow.js +138 -33
  40. package/dist/organisms/jl-tab-bar.css_raw.js +3 -0
  41. package/dist/organisms/jl-tab-bar.d.ts +43 -0
  42. package/dist/organisms/jl-tab-bar.js +123 -0
  43. package/dist/templates/jl-app-shell.css_raw.js +1 -1
  44. package/dist/templates/jl-app-shell.d.ts +8 -2
  45. package/dist/templates/jl-app-shell.js +32 -5
  46. package/dist/types/jl-chip-group.types.d.ts +10 -0
  47. package/dist/types/jl-flow.types.d.ts +17 -0
  48. package/dist/types/jl-segmented.types.d.ts +2 -0
  49. package/dist/types/jl-tab-bar.types.d.ts +11 -0
  50. package/package.json +1 -1
@@ -4,13 +4,17 @@ import { jsonOf } from "../lib/attributes.js";
4
4
  import { registerIcons } from "../lib/icons.js";
5
5
  import { setOrRemove, svgElement } from "../lib/dom.js";
6
6
  import { JlIcon } from "../atoms/jl-icon.js";
7
- import { FLOW_NODE, FLOW_ZOOM, connect, edgeEnds, edgeMidpoint, edgePath, fitView, isPortFree, layoutFlow, moveNode, nodeAt, outputAnchor, portX, portsOf, positionAfter, removeEdge, removeNode, revealIn, snap, toWorld, zoomAt } from "../lib/flow.js";
7
+ import { FLOW_NODE, FLOW_ZOOM, centeredAt, connect, edgeEnds, edgeMidpoint, edgePath, fitView, isEdgeTaken, isPortFree, layoutFlow, moveNode, nodeAt, nodeHeight, outputAnchor, portX, portsOf, positionAfter, removeEdge, removeNode, revealIn, snap, toWorld, zoomAt } from "../lib/flow.js";
8
8
  import jl_flow_default from "./jl-flow.css_raw.js";
9
9
  import { css } from "@jadis/core";
10
- import { Maximize, Minus, Plus, WandSparkles, X } from "lucide";
10
+ import { Check, CircleAlert, Hourglass, LoaderCircle, Maximize, Minus, Plus, WandSparkles, X } from "lucide";
11
11
  import { Fragment, jsx, jsxs } from "@jadis/core/jsx-runtime";
12
12
  // src/organisms/jl-flow.tsx
13
13
  registerIcons({
14
+ check: Check,
15
+ "circle-alert": CircleAlert,
16
+ hourglass: Hourglass,
17
+ "loader-circle": LoaderCircle,
14
18
  maximize: Maximize,
15
19
  minus: Minus,
16
20
  plus: Plus,
@@ -32,6 +36,7 @@ var PERCENT = 100;
32
36
  var ICON_SIZE = 16;
33
37
  var ADD_ICON_SIZE = 12;
34
38
  var CONTROL_ICON_SIZE = 14;
39
+ var STATUS_ICON_SIZE = 12;
35
40
  var PORT_GAP = 6;
36
41
  var ID_LENGTH = 8;
37
42
  var DELETE_KEYS = /* @__PURE__ */ new Set(["Delete", "Backspace"]);
@@ -53,6 +58,20 @@ var ARROWS = /* @__PURE__ */ new Map([
53
58
  y: -1
54
59
  }]
55
60
  ]);
61
+ var STATUS_ICONS = {
62
+ done: "check",
63
+ failed: "x",
64
+ running: "loader-circle",
65
+ waiting: "hourglass"
66
+ };
67
+ var STATUS_LABELS = {
68
+ done: "Done",
69
+ failed: "Failed",
70
+ running: "Running",
71
+ waiting: "Waiting"
72
+ };
73
+ /** What screen readers hear after a node's label: how it did in the run, and what to fix. */
74
+ var noteOf = (node) => [node.status ? STATUS_LABELS[node.status] : "", node.problem ?? ""].filter((part) => part !== "").join(". ");
56
75
  var markOf = (element, name) => element?.closest(`[${name}]`)?.getAttribute(name) ?? void 0;
57
76
  var isControl = (event) => event.composedPath().some((element) => element instanceof Element && element.hasAttribute("data-control"));
58
77
  function targetOf(event) {
@@ -71,7 +90,8 @@ var addLabel = (node, port) => port.label ? `Add after ${node.label}, ${port.lab
71
90
  /**
72
91
  * A flow of nodes joined by edges, to edit: people move the nodes, draw an edge from a node's exit to another node,
73
92
  * press + on a free exit to ask for the next node, and move and zoom the view. Every edit goes out as a new graph
74
- * (`change`); the page adds nodes itself, when `add` asks.
93
+ * (`change`); the page adds nodes itself, when `add` or `drop` asks. Nodes can show how a run went (`status`,
94
+ * `taken`) and what to fix (`problem`).
75
95
  */
76
96
  var JlFlow = class extends JlElement {
77
97
  static selector = "jl-flow";
@@ -90,6 +110,11 @@ var JlFlow = class extends JlElement {
90
110
  this.reflect("readonly", value);
91
111
  this.render();
92
112
  }, { attribute: "readonly" });
113
+ /**
114
+ * The drag-and-drop type the canvas takes (`application/x-step`): something dragged in carrying it asks for a node
115
+ * where it is dropped, with `drop`.
116
+ */
117
+ dropType = this.useChange("", () => void 0, { attribute: "drop-type" });
93
118
  /** The canvas's name for screen readers. */
94
119
  label = this.useChange("", (value) => this._refs.viewport.setAttribute("aria-label", value || DEFAULT_LABEL), { attribute: "label" });
95
120
  _state = {
@@ -133,6 +158,27 @@ var JlFlow = class extends JlElement {
133
158
  this.setViewport(fitView(this._state.graph.nodes, width, height));
134
159
  });
135
160
  }
161
+ /** Moves the view, when needed, so that a node is in sight: one just added, or moved with the keys. */
162
+ reveal(id) {
163
+ this.whenRendered(() => {
164
+ const node = this.nodeOf(id);
165
+ const { width, height } = this._refs.viewport.getBoundingClientRect();
166
+ if (!node || width === 0) return;
167
+ this.setViewport(revealIn(this._state.viewport, node, width, height));
168
+ });
169
+ }
170
+ /** Where a node added after an exit goes, as + puts it: a row below, under that exit. */
171
+ positionAfter(from, port) {
172
+ return positionAfter(this._state.graph, from, port);
173
+ }
174
+ /** Where a node added without a place goes: in the middle of the view (its top left corner). */
175
+ centerPosition() {
176
+ const { width, height } = this._refs.viewport.getBoundingClientRect();
177
+ return centeredAt(toWorld(this._state.viewport, {
178
+ x: width / 2,
179
+ y: height / 2
180
+ }));
181
+ }
136
182
  /** Lays the nodes out again, in rows from the starts down (`change` gives the new positions), and shows them all. */
137
183
  layout() {
138
184
  this.whenRendered(() => {
@@ -236,6 +282,8 @@ var JlFlow = class extends JlElement {
236
282
  this.on(viewport, "wheel", (event) => this.wheel(event));
237
283
  this.on(viewport, "keydown", (event) => this.key(event));
238
284
  this.on(viewport, "click", (event) => this.pressAdd(event));
285
+ this.on(viewport, "dragover", (event) => this.dragOver(event));
286
+ this.on(viewport, "drop", (event) => this.dropOn(event));
239
287
  this.on(viewport, "focusin", (event) => {
240
288
  const node = targetOf(event).node;
241
289
  if (node !== void 0) this.select(node);
@@ -424,6 +472,24 @@ var JlFlow = class extends JlElement {
424
472
  position: positionAfter(this._state.graph, node, add)
425
473
  });
426
474
  }
475
+ /** Whether a drag carries what the canvas takes: then it may be dropped here. */
476
+ carriesDrop(event) {
477
+ const type = this.dropType.get();
478
+ return type !== "" && !this.readonly.get() && (event.dataTransfer?.types.includes(type) ?? false);
479
+ }
480
+ dragOver(event) {
481
+ if (this.carriesDrop(event)) event.preventDefault();
482
+ }
483
+ dropOn(event) {
484
+ if (!this.carriesDrop(event)) return;
485
+ event.preventDefault();
486
+ event.stopPropagation();
487
+ const data = event.dataTransfer?.getData(this.dropType.get()) ?? "";
488
+ this.events.emit("drop", {
489
+ data,
490
+ position: centeredAt(this.worldPoint(event))
491
+ });
492
+ }
427
493
  key(event) {
428
494
  const target = targetOf(event);
429
495
  if (target.control || target.add !== void 0) return;
@@ -453,13 +519,6 @@ var JlFlow = class extends JlElement {
453
519
  this.commit(moveNode(this._state.graph, node.id, position));
454
520
  this.reveal(node.id);
455
521
  }
456
- /** Moves the view, when needed, so that a node is in sight: one just added, or moved with the keys. */
457
- reveal(id) {
458
- const node = this.nodeOf(id);
459
- const { width, height } = this._refs.viewport.getBoundingClientRect();
460
- if (!node || width === 0) return;
461
- this.setViewport(revealIn(this._state.viewport, node, width, height));
462
- }
463
522
  /** Two fingers or the wheel move the view; with Ctrl or ⌘ (a pinch on a trackpad), they zoom. */
464
523
  wheel(event) {
465
524
  event.preventDefault();
@@ -535,30 +594,63 @@ var JlFlow = class extends JlElement {
535
594
  drawNode(element, node) {
536
595
  const ports = portsOf(node);
537
596
  element.setAttribute("aria-label", node.label);
597
+ setOrRemove(element, "aria-description", noteOf(node));
538
598
  element.part.toggle("start", node.start === true);
539
599
  setOrRemove(element, "data-tone", node.tone ?? "");
600
+ setOrRemove(element, "data-status", node.status ?? "");
601
+ element.style.height = `${nodeHeight(node)}px`;
602
+ this.paintIcon(element, node.color ?? "");
540
603
  element.replaceChildren(/* @__PURE__ */ jsxs(Fragment, { children: [
541
604
  node.start === true ? null : /* @__PURE__ */ jsx("span", { class: "input" }),
542
- node.icon ? /* @__PURE__ */ jsx("span", {
543
- class: "icon",
544
- attrs: { part: "icon" },
545
- children: /* @__PURE__ */ jsx(JlIcon, {
546
- name: node.icon,
547
- size: ICON_SIZE
548
- })
549
- }) : null,
550
605
  /* @__PURE__ */ jsxs("span", {
551
- class: "text",
552
- children: [/* @__PURE__ */ jsx("span", {
553
- class: "label",
554
- attrs: { part: "label" },
555
- children: node.label
556
- }), node.description ? /* @__PURE__ */ jsx("span", {
557
- class: "description",
558
- attrs: { part: "description" },
559
- children: node.description
560
- }) : null]
606
+ class: "head",
607
+ children: [
608
+ node.icon ? /* @__PURE__ */ jsx("span", {
609
+ class: "icon",
610
+ attrs: { part: "icon" },
611
+ children: /* @__PURE__ */ jsx(JlIcon, {
612
+ name: node.icon,
613
+ size: ICON_SIZE
614
+ })
615
+ }) : null,
616
+ /* @__PURE__ */ jsxs("span", {
617
+ class: "text",
618
+ children: [/* @__PURE__ */ jsx("span", {
619
+ class: "label",
620
+ attrs: { part: "label" },
621
+ children: node.label
622
+ }), node.description ? /* @__PURE__ */ jsx("span", {
623
+ class: "description",
624
+ attrs: { part: "description" },
625
+ children: node.description
626
+ }) : null]
627
+ }),
628
+ node.problem ? /* @__PURE__ */ jsx("span", {
629
+ class: "problem",
630
+ title: node.problem,
631
+ attrs: { part: "problem" },
632
+ children: /* @__PURE__ */ jsx(JlIcon, {
633
+ name: "circle-alert",
634
+ size: ICON_SIZE
635
+ })
636
+ }) : null,
637
+ node.status ? /* @__PURE__ */ jsx("span", {
638
+ class: "status",
639
+ title: STATUS_LABELS[node.status],
640
+ attrs: { part: "status" },
641
+ children: /* @__PURE__ */ jsx(JlIcon, {
642
+ name: STATUS_ICONS[node.status],
643
+ size: STATUS_ICON_SIZE
644
+ })
645
+ }) : null
646
+ ]
561
647
  }),
648
+ node.detail ? /* @__PURE__ */ jsx("span", {
649
+ class: "detail",
650
+ title: node.detail,
651
+ attrs: { part: "detail" },
652
+ children: node.detail
653
+ }) : null,
562
654
  /* @__PURE__ */ jsx("span", {
563
655
  class: "ports",
564
656
  children: ports.map((port, index) => this.portElement(node, port, index, ports.length))
@@ -566,6 +658,14 @@ var JlFlow = class extends JlElement {
566
658
  ] }));
567
659
  this.place(element, node.position);
568
660
  }
661
+ /** A colour of the page's own for the icon wins over the tone's. */
662
+ paintIcon(element, color) {
663
+ if (color === "") {
664
+ element.style.removeProperty("--tone");
665
+ return;
666
+ }
667
+ element.style.setProperty("--tone", color);
668
+ }
569
669
  portElement(node, port, index, count) {
570
670
  const free = !this.readonly.get() && isPortFree(this._state.graph, node, port.id);
571
671
  const element = /* @__PURE__ */ jsxs("span", {
@@ -594,6 +694,7 @@ var JlFlow = class extends JlElement {
594
694
  element.style.left = `${portX(index, count)}px`;
595
695
  element.style.maxWidth = `${FLOW_NODE.width / count - PORT_GAP}px`;
596
696
  setOrRemove(element, "data-tone", port.tone ?? "");
697
+ element.toggleAttribute("data-taken", node.taken === port.id);
597
698
  return element;
598
699
  }
599
700
  place(element, position) {
@@ -611,16 +712,20 @@ var JlFlow = class extends JlElement {
611
712
  const { edge: selected, graph } = this._state;
612
713
  const groups = graph.edges.flatMap((edge) => {
613
714
  const ends = edgeEnds(graph, edge);
614
- return ends ? [this.edgeGroup(edge.id, ends, edge.id === selected)] : [];
715
+ return ends ? [this.edgeGroup(edge.id, ends, edge.id === selected, isEdgeTaken(graph, edge))] : [];
615
716
  });
616
717
  this._svg.root.replaceChildren(...groups, this._svg.draft);
617
718
  this.renderEdgeTool();
618
719
  }
619
- /** An edge, with a wide invisible twin that catches the pointer: a thin line is still easy to press. */
620
- edgeGroup(id, ends, selected) {
720
+ /**
721
+ * An edge, with a wide invisible twin that catches the pointer: a thin line is still easy to press. One a run took
722
+ * stands out.
723
+ */
724
+ edgeGroup(id, ends, selected, taken) {
621
725
  const d = edgePath(ends.from, ends.to);
726
+ const states = [selected ? "selected" : "", taken ? "taken" : ""].filter((state) => state !== "");
622
727
  const group = svgElement("g", {
623
- class: selected ? "edge-group selected" : "edge-group",
728
+ class: ["edge-group", ...states].join(" "),
624
729
  "data-edge": id
625
730
  });
626
731
  group.append(svgElement("path", {
@@ -630,7 +735,7 @@ var JlFlow = class extends JlElement {
630
735
  class: "edge",
631
736
  d,
632
737
  "data-tone": ends.tone,
633
- part: selected ? "edge selected" : "edge"
738
+ part: ["edge", ...states].join(" ")
634
739
  }));
635
740
  return group;
636
741
  }
@@ -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 };
@@ -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,10 @@
1
+ /** One choice of a chip group. */
2
+ export type ChipGroupOption = {
3
+ value: string;
4
+ label: string;
5
+ /** An icon name before the label; a chosen chip without one shows a check. */
6
+ icon?: string;
7
+ /** A count or a short word after the label: how many things the choice holds. */
8
+ badge?: string | number;
9
+ disabled?: boolean;
10
+ };
@@ -10,6 +10,8 @@ export type FlowPort = {
10
10
  label?: string;
11
11
  tone?: Tone;
12
12
  };
13
+ /** How a node did in a run, shown on it: at work, finished, waiting for someone, or failed. */
14
+ export type FlowNodeStatus = 'running' | 'done' | 'waiting' | 'failed';
13
15
  export type FlowNode = {
14
16
  id: string;
15
17
  label: string;
@@ -17,9 +19,19 @@ export type FlowNode = {
17
19
  position: FlowPoint;
18
20
  /** A second, quieter line under the label. */
19
21
  description?: string;
22
+ /** A third line, under a dashed rule: what the node is set to do. The node is taller with it. */
23
+ detail?: string;
20
24
  icon?: string;
21
25
  /** The colour of the icon: `accent` by default. */
22
26
  tone?: Tone;
27
+ /** The icon's colour as any CSS colour, for kinds the tones do not cover (`var(--my-ai)`); wins over `tone`. */
28
+ color?: string;
29
+ /** How it did in a run. */
30
+ status?: FlowNodeStatus;
31
+ /** The exit a run left it by: that exit, and its edges to nodes with a status, stand out. */
32
+ taken?: string;
33
+ /** Something to fix before the flow can run: a mark on the node, with this text as its explanation. */
34
+ problem?: string;
23
35
  /** The exits. Absent, the node has one without a name; empty, it has none (it ends the flow). */
24
36
  ports?: FlowPort[];
25
37
  /** A start takes no incoming edge. */
@@ -42,6 +54,11 @@ export type FlowAddRequest = {
42
54
  port: string;
43
55
  position: FlowPoint;
44
56
  };
57
+ /** Asks for a node where something was dropped: what it carried, and where to put the node. */
58
+ export type FlowDropRequest = {
59
+ data: string;
60
+ position: FlowPoint;
61
+ };
45
62
  /** How far the view is moved, and how much it is zoomed. */
46
63
  export type FlowViewport = {
47
64
  x: number;
@@ -3,5 +3,7 @@ export type SegmentedOption = {
3
3
  value: string;
4
4
  label?: string;
5
5
  icon?: string;
6
+ /** A count or a short word after the label: what waits behind the choice. */
7
+ badge?: string | number;
6
8
  disabled?: boolean;
7
9
  };