@syware/jlib 0.3.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.
package/dist/all.js CHANGED
@@ -34,6 +34,7 @@ import { JlAvatarGroup } from "./molecules/jl-avatar-group.js";
34
34
  import { JlBreadcrumb } from "./molecules/jl-breadcrumb.js";
35
35
  import { JlCalendar } from "./molecules/jl-calendar.js";
36
36
  import { CARD_PADDINGS, JlCard } from "./molecules/jl-card.js";
37
+ import { JlChipGroup } from "./molecules/jl-chip-group.js";
37
38
  import { JlCopyButton } from "./molecules/jl-copy-button.js";
38
39
  import { CODE_LANGUAGES, JlCodeBlock } from "./molecules/jl-code-block.js";
39
40
  import { JlCombobox } from "./molecules/jl-combobox.js";
@@ -65,6 +66,7 @@ import { JlFlow } from "./organisms/jl-flow.js";
65
66
  import { JlNavbar } from "./organisms/jl-navbar.js";
66
67
  import { JlPageHeader } from "./organisms/jl-page-header.js";
67
68
  import { JlSideNav } from "./organisms/jl-side-nav.js";
69
+ import { JlTabBar } from "./organisms/jl-tab-bar.js";
68
70
  import { JlTable, TABLE_SORT_DIRECTIONS } from "./organisms/jl-table.js";
69
71
  import { JlToaster, TOASTER_PLACEMENTS, toast } from "./organisms/jl-toaster.js";
70
72
  import { JlTree } from "./organisms/jl-tree.js";
@@ -72,5 +74,5 @@ import { JlAppShell } from "./templates/jl-app-shell.js";
72
74
  import { JlSplitView, SPLIT_ORIENTATIONS } from "./templates/jl-split-view.js";
73
75
  // src/all.ts
74
76
  registerIcons(COMMON_ICONS);
75
- define(JlAccordion, JlAccordionItem, JlAlert, JlAppShell, JlAvatar, JlAvatarGroup, JlBadge, JlBreadcrumb, JlButton, JlCalendar, JlCard, JlCheckbox, JlChip, JlCodeBlock, JlCombobox, JlCommandPalette, JlConfirm, JlCopyButton, JlDatePicker, JlDialog, JlDivider, JlDrawer, JlEmpty, JlField, JlFileDrop, JlFlow, JlIcon, JlInput, JlKbd, JlLink, JlListItem, JlMenu, JlNavbar, JlNumberInput, JlPageHeader, JlPagination, JlPopover, JlProgress, JlProgressRing, JlRadioGroup, JlRating, JlSearch, JlSegmented, JlSelect, JlSideNav, JlSkeleton, JlSlider, JlSpinner, JlSplitView, JlStat, JlSteps, JlSwitch, JlTable, JlTabPanel, JlTabs, JlTagInput, JlTextarea, JlTimeline, JlToast, JlToaster, JlTooltip, JlTree);
76
- export { ACCORDION_VARIANTS, AVATAR_SHAPES, AVATAR_SIZES, AVATAR_STATUSES, BASE_STYLES, BUTTON_TYPES, BUTTON_VARIANTS, CARD_PADDINGS, CHIP_SIZES, CODE_LANGUAGES, COMMON_ICONS, CONFIRM_TONES, CONTROL_SIZES, CONTROL_STYLES, DIALOG_SIZES, DIVIDER_ALIGNS, DRAWER_PLACEMENTS, DRAWER_SIZES, EMPTY_SIZES, FIELD_ORIENTATIONS, FLOATING_STYLES, INPUT_TYPES, JlAccordion, JlAccordionItem, JlAlert, JlAppShell, JlAvatar, JlAvatarGroup, JlBadge, JlBreadcrumb, JlButton, JlCalendar, JlCard, JlCheckbox, JlChip, JlCodeBlock, JlCombobox, JlCommandPalette, JlConfirm, JlCopyButton, JlDatePicker, JlDialog, JlDivider, JlDrawer, JlElement, JlEmpty, JlField, JlFileDrop, JlFlow, JlFormControl, JlIcon, JlInput, JlKbd, JlLink, JlListItem, JlMenu, JlNavbar, JlNumberInput, JlPageHeader, JlPagination, JlPopover, JlProgress, JlProgressRing, JlRadioGroup, JlRating, JlSearch, JlSegmented, JlSelect, JlSideNav, JlSkeleton, JlSlider, JlSpinner, JlSplitView, JlStat, JlSteps, JlSwitch, JlTabPanel, JlTable, JlTabs, JlTagInput, JlTextarea, JlTimeline, JlToast, JlToaster, JlTooltip, JlTree, LINK_VARIANTS, PLACEMENTS, PROGRESS_SIZES, PROGRESS_TONES, RADIO_GROUP_ORIENTATIONS, RADIO_GROUP_VARIANTS, SKELETON_SHAPES, SPLIT_ORIENTATIONS, STAT_TRENDS, STEPS_ORIENTATIONS, SWITCH_SIZES, TABLE_SORT_DIRECTIONS, TAB_VARIANTS, TOASTER_PLACEMENTS, TOAST_TONES, TOKEN_STYLES, TONES, TONE_ICONS, TONE_ICON_FILLED, adoptTokens, clampedNumber, computePosition, confirmDialog, define, followAnchor, hasIcon, iconElement, iconNames, jsonOf, oneOf, placeFloating, registerIcons, toast };
77
+ define(JlAccordion, JlAccordionItem, JlAlert, JlAppShell, JlAvatar, JlAvatarGroup, JlBadge, JlBreadcrumb, JlButton, JlCalendar, JlCard, JlCheckbox, JlChip, JlChipGroup, JlCodeBlock, JlCombobox, JlCommandPalette, JlConfirm, JlCopyButton, JlDatePicker, JlDialog, JlDivider, JlDrawer, JlEmpty, JlField, JlFileDrop, JlFlow, JlIcon, JlInput, JlKbd, JlLink, JlListItem, JlMenu, JlNavbar, JlNumberInput, JlPageHeader, JlPagination, JlPopover, JlProgress, JlProgressRing, JlRadioGroup, JlRating, JlSearch, JlSegmented, JlSelect, JlSideNav, JlSkeleton, JlSlider, JlSpinner, JlSplitView, JlStat, JlSteps, JlSwitch, JlTabBar, JlTable, JlTabPanel, JlTabs, JlTagInput, JlTextarea, JlTimeline, JlToast, JlToaster, JlTooltip, JlTree);
78
+ export { ACCORDION_VARIANTS, AVATAR_SHAPES, AVATAR_SIZES, AVATAR_STATUSES, BASE_STYLES, BUTTON_TYPES, BUTTON_VARIANTS, CARD_PADDINGS, CHIP_SIZES, CODE_LANGUAGES, COMMON_ICONS, CONFIRM_TONES, CONTROL_SIZES, CONTROL_STYLES, DIALOG_SIZES, DIVIDER_ALIGNS, DRAWER_PLACEMENTS, DRAWER_SIZES, EMPTY_SIZES, FIELD_ORIENTATIONS, FLOATING_STYLES, INPUT_TYPES, JlAccordion, JlAccordionItem, JlAlert, JlAppShell, JlAvatar, JlAvatarGroup, JlBadge, JlBreadcrumb, JlButton, JlCalendar, JlCard, JlCheckbox, JlChip, JlChipGroup, JlCodeBlock, JlCombobox, JlCommandPalette, JlConfirm, JlCopyButton, JlDatePicker, JlDialog, JlDivider, JlDrawer, JlElement, JlEmpty, JlField, JlFileDrop, JlFlow, JlFormControl, JlIcon, JlInput, JlKbd, JlLink, JlListItem, JlMenu, JlNavbar, JlNumberInput, JlPageHeader, JlPagination, JlPopover, JlProgress, JlProgressRing, JlRadioGroup, JlRating, JlSearch, JlSegmented, JlSelect, JlSideNav, JlSkeleton, JlSlider, JlSpinner, JlSplitView, JlStat, JlSteps, JlSwitch, JlTabBar, JlTabPanel, JlTable, JlTabs, JlTagInput, JlTextarea, JlTimeline, JlToast, JlToaster, JlTooltip, JlTree, LINK_VARIANTS, PLACEMENTS, PROGRESS_SIZES, PROGRESS_TONES, RADIO_GROUP_ORIENTATIONS, RADIO_GROUP_VARIANTS, SKELETON_SHAPES, SPLIT_ORIENTATIONS, STAT_TRENDS, STEPS_ORIENTATIONS, SWITCH_SIZES, TABLE_SORT_DIRECTIONS, TAB_VARIANTS, TOASTER_PLACEMENTS, TOAST_TONES, TOKEN_STYLES, TONES, TONE_ICONS, TONE_ICON_FILLED, adoptTokens, clampedNumber, computePosition, confirmDialog, define, followAnchor, hasIcon, iconElement, iconNames, jsonOf, oneOf, placeFloating, registerIcons, toast };
@@ -1,3 +1,3 @@
1
1
  // src/atoms/jl-button.css?raw
2
- var jl_button_default = ":host {\n display: inline-flex;\n vertical-align: middle;\n --height: var(--jl-control-md);\n --pad: 14px;\n --font-size: var(--jl-font-md);\n --radius: var(--jl-radius-sm);\n}\n\n:host([size=\"sm\"]) {\n --height: var(--jl-control-sm);\n --pad: 11px;\n --font-size: var(--jl-font-xs);\n}\n\n:host([size=\"lg\"]) {\n --height: var(--jl-control-lg);\n --pad: 18px;\n --font-size: 15px;\n --radius: var(--jl-radius);\n}\n\n:host([block]) {\n display: flex;\n}\n\nbutton {\n display: inline-flex;\n flex: 1;\n align-items: center;\n justify-content: center;\n gap: 8px;\n height: var(--height);\n padding: 0 var(--pad);\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--radius);\n background: var(--jl-surface);\n color: var(--jl-text);\n font-size: var(--font-size);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n transition:\n background var(--jl-fast) var(--jl-ease),\n border-color var(--jl-fast) var(--jl-ease),\n color var(--jl-fast) var(--jl-ease);\n}\n\n:host([icon-only]) button {\n width: var(--height);\n padding: 0;\n}\n\nbutton:hover {\n background: var(--jl-surface-2);\n}\n\nbutton:active {\n background: var(--jl-surface-3);\n}\n\n:host([variant=\"primary\"]) button {\n border-color: transparent;\n background: var(--jl-accent);\n color: var(--jl-on-accent);\n}\n\n:host([variant=\"primary\"]) button:hover {\n background: var(--jl-accent-strong);\n}\n\n:host([variant=\"ghost\"]) button {\n border-color: transparent;\n background: transparent;\n color: var(--jl-accent);\n}\n\n:host([variant=\"ghost\"]) button:hover {\n background: var(--jl-accent-soft);\n}\n\n:host([variant=\"danger\"]) button {\n border-color: transparent;\n background: var(--jl-danger);\n color: var(--jl-on-danger);\n}\n\n:host([variant=\"danger\"]) button:hover {\n background: color-mix(in srgb, var(--jl-danger) 86%, var(--jl-text));\n}\n\n:host([loading]) button {\n cursor: progress;\n}\n\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n";
2
+ var jl_button_default = ":host {\n display: inline-flex;\n vertical-align: middle;\n --height: var(--jl-control-md);\n --pad: 14px;\n --font-size: var(--jl-font-md);\n --radius: var(--jl-radius-sm);\n}\n\n:host([size=\"sm\"]) {\n --height: var(--jl-control-sm);\n --pad: 11px;\n --font-size: var(--jl-font-xs);\n}\n\n:host([size=\"lg\"]) {\n --height: var(--jl-control-lg);\n --pad: 18px;\n --font-size: 15px;\n --radius: var(--jl-radius);\n}\n\n:host([block]) {\n display: flex;\n}\n\n/* Moves between the button and the link: its children are laid out by whichever holds it. */\n.content {\n display: contents;\n}\n\n.base {\n display: inline-flex;\n flex: 1;\n align-items: center;\n justify-content: center;\n gap: 8px;\n height: var(--height);\n padding: 0 var(--pad);\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--radius);\n background: var(--jl-surface);\n color: var(--jl-text);\n font-size: var(--font-size);\n font-weight: 600;\n line-height: 1;\n text-decoration: none;\n white-space: nowrap;\n cursor: pointer;\n transition:\n background var(--jl-fast) var(--jl-ease),\n border-color var(--jl-fast) var(--jl-ease),\n color var(--jl-fast) var(--jl-ease);\n}\n\n:host([icon-only]) .base {\n width: var(--height);\n padding: 0;\n}\n\n.base:hover {\n background: var(--jl-surface-2);\n}\n\n.base:active {\n background: var(--jl-surface-3);\n}\n\n:host([variant=\"primary\"]) .base {\n border-color: transparent;\n background: var(--jl-accent);\n color: var(--jl-on-accent);\n}\n\n:host([variant=\"primary\"]) .base:hover {\n background: var(--jl-accent-strong);\n}\n\n:host([variant=\"ghost\"]) .base {\n border-color: transparent;\n background: transparent;\n color: var(--jl-accent);\n}\n\n:host([variant=\"ghost\"]) .base:hover {\n background: var(--jl-accent-soft);\n}\n\n:host([variant=\"danger\"]) .base {\n border-color: transparent;\n background: var(--jl-danger);\n color: var(--jl-on-danger);\n}\n\n:host([variant=\"danger\"]) .base:hover {\n background: color-mix(in srgb, var(--jl-danger) 86%, var(--jl-text));\n}\n\n:host([loading]) .base {\n cursor: progress;\n}\n\n.base:disabled,\n.base[aria-disabled=\"true\"] {\n cursor: not-allowed;\n opacity: 0.5;\n}\n";
3
3
  export { jl_button_default as default };
@@ -6,6 +6,7 @@ export declare const BUTTON_TYPES: readonly ButtonType[];
6
6
  /**
7
7
  * A button. Its text is the `label` (or the slotted content); with only an `icon`, its `hint` names it.
8
8
  * Listen to `press`: it fires only while the button can be pressed. `type="submit"` submits the surrounding form.
9
+ * With an `href` it is a link that looks the same: it opens the address, and `press` still fires.
9
10
  */
10
11
  export declare class JlButton extends JlElement {
11
12
  static readonly selector = "jl-button";
@@ -56,12 +57,30 @@ export declare class JlButton extends JlElement {
56
57
  get(): boolean;
57
58
  set(setter: boolean | ((prevState: boolean) => boolean)): void;
58
59
  }>>;
60
+ /** An address to open: the button becomes a link, for navigation that should open in a new tab when asked. */
61
+ readonly href: Readonly<Readonly<{
62
+ get(): string;
63
+ set(setter: string | ((prevState: string) => string)): void;
64
+ }>>;
65
+ /** Where the link opens (`_blank` for a new tab), with an `href`. */
66
+ readonly target: Readonly<Readonly<{
67
+ get(): string;
68
+ set(setter: string | ((prevState: string) => string)): void;
69
+ }>>;
59
70
  private readonly _internals;
60
71
  private readonly _refs;
61
72
  focus(options?: FocusOptions): void;
62
73
  templateHtml(): Node;
63
74
  templateCss(): string;
64
75
  onConnect(): void;
76
+ private canPress;
77
+ /** The element that takes focus and clicks: the link with an `href`, the button otherwise. */
78
+ private control;
79
+ /**
80
+ * Moves the content into the link or back into the button. A disabled link loses its address, so it can be
81
+ * neither followed nor focused, and says it is disabled.
82
+ */
83
+ private renderLink;
65
84
  private actOnForm;
66
85
  private renderIcon;
67
86
  private renderSize;
@@ -2,11 +2,12 @@ import { BASE_STYLES } from "../styles/shared.js";
2
2
  import { JlElement } from "../base/jl-element.js";
3
3
  import { oneOf } from "../lib/attributes.js";
4
4
  import { CONTROL_SIZES } from "../lib/options.js";
5
+ import { setOrRemove } from "../lib/dom.js";
5
6
  import { JlIcon } from "./jl-icon.js";
6
7
  import jl_button_default from "./jl-button.css_raw.js";
7
8
  import { JlSpinner } from "./jl-spinner.js";
8
9
  import { css } from "@jadis/core";
9
- import { jsx, jsxs } from "@jadis/core/jsx-runtime";
10
+ import { Fragment, jsx, jsxs } from "@jadis/core/jsx-runtime";
10
11
  // src/atoms/jl-button.tsx
11
12
  var BUTTON_VARIANTS = [
12
13
  "secondary",
@@ -24,9 +25,13 @@ var ICON_SIZES = {
24
25
  md: 16,
25
26
  sm: 14
26
27
  };
28
+ var NEW_TAB = "_blank";
29
+ /** A page opened in a new tab gets no handle on this one. */
30
+ var SAFE_REL = "noopener noreferrer";
27
31
  /**
28
32
  * A button. Its text is the `label` (or the slotted content); with only an `icon`, its `hint` names it.
29
33
  * Listen to `press`: it fires only while the button can be pressed. `type="submit"` submits the surrounding form.
34
+ * With an `href` it is a link that looks the same: it opens the address, and `press` still fires.
30
35
  */
31
36
  var JlButton = class extends JlElement {
32
37
  static selector = "jl-button";
@@ -49,6 +54,7 @@ var JlButton = class extends JlElement {
49
54
  });
50
55
  disabled = this.useChange(false, (value) => {
51
56
  this._refs.button.disabled = value;
57
+ this.renderLink();
52
58
  this.reflect("disabled", value);
53
59
  }, { attribute: "disabled" });
54
60
  /** Shows a spinner in place of the icon and ignores presses, while the work it started runs. */
@@ -56,42 +62,57 @@ var JlButton = class extends JlElement {
56
62
  this._refs.spinner.hidden = !value;
57
63
  this._refs.start.hidden = value || this.icon.get() === "";
58
64
  this._refs.button.toggleAttribute("aria-busy", value);
65
+ this._refs.link.toggleAttribute("aria-busy", value);
59
66
  this.reflect("loading", value);
60
67
  }, { attribute: "loading" });
61
68
  /** The tooltip; also the button's name for screen readers when it only has an icon. */
62
69
  hint = this.useChange("", () => this.renderLabel(), { attribute: "hint" });
63
70
  /** Takes the whole width of its container. */
64
71
  block = this.useChange(false, (value) => this.reflect("block", value), { attribute: "block" });
72
+ /** An address to open: the button becomes a link, for navigation that should open in a new tab when asked. */
73
+ href = this.useChange("", () => this.renderLink(), { attribute: "href" });
74
+ /** Where the link opens (`_blank` for a new tab), with an `href`. */
75
+ target = this.useChange("", () => this.renderLink(), { attribute: "target" });
65
76
  _internals = this.attachInternals();
66
77
  _refs = this.useRefs((ref) => ({
67
78
  button: ref("button"),
79
+ content: ref(".content"),
68
80
  end: ref(".end"),
69
81
  label: ref(".text"),
82
+ link: ref("a"),
70
83
  slot: ref("slot"),
71
84
  spinner: ref("jl-spinner"),
72
85
  start: ref(".start")
73
86
  }));
74
87
  focus(options) {
75
- this._refs.button.focus(options);
88
+ this.control().focus(options);
76
89
  }
77
90
  templateHtml() {
78
- return /* @__PURE__ */ jsxs("button", {
91
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
92
+ class: "base",
79
93
  type: "button",
80
94
  attrs: { part: "base" },
81
- children: [
82
- /* @__PURE__ */ jsx(JlSpinner, { hidden: true }),
83
- /* @__PURE__ */ jsx(JlIcon, {
84
- class: "start",
85
- hidden: true
86
- }),
87
- /* @__PURE__ */ jsx("span", { class: "text" }),
88
- /* @__PURE__ */ jsx("slot", {}),
89
- /* @__PURE__ */ jsx(JlIcon, {
90
- class: "end",
91
- hidden: true
92
- })
93
- ]
94
- });
95
+ children: /* @__PURE__ */ jsxs("span", {
96
+ class: "content",
97
+ children: [
98
+ /* @__PURE__ */ jsx(JlSpinner, { hidden: true }),
99
+ /* @__PURE__ */ jsx(JlIcon, {
100
+ class: "start",
101
+ hidden: true
102
+ }),
103
+ /* @__PURE__ */ jsx("span", { class: "text" }),
104
+ /* @__PURE__ */ jsx("slot", {}),
105
+ /* @__PURE__ */ jsx(JlIcon, {
106
+ class: "end",
107
+ hidden: true
108
+ })
109
+ ]
110
+ })
111
+ }), /* @__PURE__ */ jsx("a", {
112
+ class: "base",
113
+ hidden: true,
114
+ attrs: { part: "base" }
115
+ })] });
95
116
  }
96
117
  templateCss() {
97
118
  return css`${BASE_STYLES}${jl_button_default}`;
@@ -99,10 +120,43 @@ var JlButton = class extends JlElement {
99
120
  onConnect() {
100
121
  this.on(this._refs.slot, "slotchange", () => this.renderLabel());
101
122
  this.on(this._refs.button, "click", () => {
102
- if (this.disabled.get() || this.loading.get()) return;
123
+ if (!this.canPress()) return;
103
124
  this.events.emit("press");
104
125
  this.actOnForm();
105
126
  });
127
+ this.on(this._refs.link, "click", (event) => {
128
+ if (!this.canPress()) {
129
+ event.preventDefault();
130
+ return;
131
+ }
132
+ this.events.emit("press");
133
+ });
134
+ }
135
+ canPress() {
136
+ return !(this.disabled.get() || this.loading.get());
137
+ }
138
+ /** The element that takes focus and clicks: the link with an `href`, the button otherwise. */
139
+ control() {
140
+ return this.href.get() === "" ? this._refs.button : this._refs.link;
141
+ }
142
+ /**
143
+ * Moves the content into the link or back into the button. A disabled link loses its address, so it can be
144
+ * neither followed nor focused, and says it is disabled.
145
+ */
146
+ renderLink() {
147
+ const { button, content, link } = this._refs;
148
+ const href = this.href.get();
149
+ const isLink = href !== "";
150
+ const target = this.target.get();
151
+ const off = isLink && this.disabled.get();
152
+ this.control().append(content);
153
+ button.hidden = isLink;
154
+ link.hidden = !isLink;
155
+ setOrRemove(link, "href", isLink && !off ? href : "");
156
+ setOrRemove(link, "target", target);
157
+ setOrRemove(link, "rel", target === NEW_TAB ? SAFE_REL : "");
158
+ setOrRemove(link, "role", off ? "link" : "");
159
+ setOrRemove(link, "aria-disabled", off ? "true" : "");
106
160
  }
107
161
  actOnForm() {
108
162
  const form = this._internals.form;
@@ -133,13 +187,11 @@ var JlButton = class extends JlElement {
133
187
  const iconOnly = label === "" && !this.hasSlottedContent();
134
188
  this._refs.label.textContent = label;
135
189
  this._refs.label.hidden = label === "";
136
- this._refs.button.title = hint;
137
190
  this.reflect("icon-only", iconOnly);
138
- if (iconOnly && hint !== "") {
139
- this._refs.button.setAttribute("aria-label", hint);
140
- return;
191
+ for (const control of [this._refs.button, this._refs.link]) {
192
+ setOrRemove(control, "title", hint);
193
+ setOrRemove(control, "aria-label", iconOnly ? hint : "");
141
194
  }
142
- this._refs.button.removeAttribute("aria-label");
143
195
  }
144
196
  };
145
197
  JlButton.register();
@@ -1,3 +1,3 @@
1
1
  // src/atoms/jl-chip.css?raw
2
- var jl_chip_default = ":host {\n display: inline-flex;\n max-width: 100%;\n vertical-align: middle;\n --height: 28px;\n --pad: 10px;\n --font-size: var(--jl-font-sm);\n --dismiss: 20px;\n}\n\n:host([size=\"sm\"]) {\n --height: 24px;\n --pad: 8px;\n --font-size: var(--jl-font-xs);\n --dismiss: 18px;\n}\n\n.chip {\n display: inline-flex;\n align-items: center;\n min-width: 0;\n height: var(--height);\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--jl-radius-pill);\n background: var(--jl-surface);\n color: var(--jl-text-2);\n font-size: var(--font-size);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n transition:\n background var(--jl-fast) var(--jl-ease),\n border-color var(--jl-fast) var(--jl-ease),\n color var(--jl-fast) var(--jl-ease);\n}\n\n.main {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n height: 100%;\n padding: 0 var(--pad);\n border-radius: inherit;\n}\n\n.label {\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.label:empty {\n display: none;\n}\n\n:host([removable]) .main {\n padding-right: 4px;\n}\n\n:host([selectable]) .main {\n cursor: pointer;\n}\n\n:host([selectable]:not([disabled])) .chip:hover {\n background: var(--jl-surface-2);\n}\n\n:host([selected]) .chip,\n:host([selected][selectable]:not([disabled])) .chip:hover {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent);\n}\n\n/* The ring goes around the whole chip, which the main part fills. */\n.main:focus-visible {\n box-shadow: none;\n}\n\n.chip:has(.main:focus-visible) {\n box-shadow: var(--jl-focus-ring);\n}\n\n.dismiss {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: var(--dismiss);\n height: var(--dismiss);\n margin-right: 3px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n background: transparent;\n color: var(--jl-muted);\n cursor: pointer;\n}\n\n.dismiss:hover {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n:host([selected]) .dismiss {\n color: var(--jl-accent);\n}\n\n:host([selected]) .dismiss:hover {\n background: color-mix(in srgb, var(--jl-accent) 14%, transparent);\n color: var(--jl-accent-strong);\n}\n\n:host([disabled]) .chip {\n opacity: 0.5;\n}\n\n:host([disabled]) .main,\n:host([disabled]) .dismiss {\n cursor: not-allowed;\n}\n\n:host([disabled]) .dismiss:hover {\n background: transparent;\n}\n";
2
+ var jl_chip_default = ":host {\n display: inline-flex;\n max-width: 100%;\n vertical-align: middle;\n --height: 28px;\n --pad: 10px;\n --font-size: var(--jl-font-sm);\n --dismiss: 20px;\n}\n\n:host([size=\"sm\"]) {\n --height: 24px;\n --pad: 8px;\n --font-size: var(--jl-font-xs);\n --dismiss: 18px;\n}\n\n.chip {\n display: inline-flex;\n align-items: center;\n min-width: 0;\n height: var(--height);\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--jl-radius-pill);\n background: var(--jl-surface);\n color: var(--jl-text-2);\n font-size: var(--font-size);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n transition:\n background var(--jl-fast) var(--jl-ease),\n border-color var(--jl-fast) var(--jl-ease),\n color var(--jl-fast) var(--jl-ease);\n}\n\n.main {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n height: 100%;\n padding: 0 var(--pad);\n border-radius: inherit;\n}\n\n/* A line box taller than the chip's own line-height of 1: the accents of É, Â, Ö stay inside what overflow clips. */\n.label {\n overflow: hidden;\n line-height: 1.4;\n text-overflow: ellipsis;\n}\n\n.label:empty {\n display: none;\n}\n\n:host([removable]) .main {\n padding-right: 4px;\n}\n\n:host([selectable]) .main {\n cursor: pointer;\n}\n\n:host([selectable]:not([disabled])) .chip:hover {\n background: var(--jl-surface-2);\n}\n\n:host([selected]) .chip,\n:host([selected][selectable]:not([disabled])) .chip:hover {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent);\n}\n\n/* The ring goes around the whole chip, which the main part fills. */\n.main:focus-visible {\n box-shadow: none;\n}\n\n.chip:has(.main:focus-visible) {\n box-shadow: var(--jl-focus-ring);\n}\n\n.dismiss {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: var(--dismiss);\n height: var(--dismiss);\n margin-right: 3px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n background: transparent;\n color: var(--jl-muted);\n cursor: pointer;\n}\n\n.dismiss:hover {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n:host([selected]) .dismiss {\n color: var(--jl-accent);\n}\n\n:host([selected]) .dismiss:hover {\n background: color-mix(in srgb, var(--jl-accent) 14%, transparent);\n color: var(--jl-accent-strong);\n}\n\n:host([disabled]) .chip {\n opacity: 0.5;\n}\n\n:host([disabled]) .main,\n:host([disabled]) .dismiss {\n cursor: not-allowed;\n}\n\n:host([disabled]) .dismiss:hover {\n background: transparent;\n}\n";
3
3
  export { jl_chip_default as default };
@@ -34,6 +34,11 @@ export declare class JlInput extends JlFormControl {
34
34
  get(): string;
35
35
  set(setter: string | ((prevState: string) => string)): void;
36
36
  }>>;
37
+ /** The keyboard a phone shows for the field (`numeric`, `decimal`, `tel`…), as a native input's `inputmode`. */
38
+ readonly inputmode: Readonly<Readonly<{
39
+ get(): string;
40
+ set(setter: string | ((prevState: string) => string)): void;
41
+ }>>;
37
42
  readonly min: Readonly<Readonly<{
38
43
  get(): string;
39
44
  set(setter: string | ((prevState: string) => string)): void;
@@ -50,6 +50,8 @@ var JlInput = class extends JlFormControl {
50
50
  /** The field's name for screen readers, when no visible label names it. */
51
51
  label = this.useChange("", (value) => this.setNative("aria-label", value), { attribute: "label" });
52
52
  autocomplete = this.useChange("", (value) => this.setNative("autocomplete", value), { attribute: "autocomplete" });
53
+ /** The keyboard a phone shows for the field (`numeric`, `decimal`, `tel`…), as a native input's `inputmode`. */
54
+ inputmode = this.useChange("", (value) => this.setNative("inputmode", value), { attribute: "inputmode" });
53
55
  min = this.useChange("", (value) => this.setNative("min", value), { attribute: "min" });
54
56
  max = this.useChange("", (value) => this.setNative("max", value), { attribute: "max" });
55
57
  step = this.useChange("", (value) => this.setNative("step", value), { attribute: "step" });
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export type * from './types/jl-avatar.types';
19
19
  export type * from './types/jl-breadcrumb.types';
20
20
  export type * from './types/jl-button.types';
21
21
  export type * from './types/jl-card.types';
22
+ export type * from './types/jl-chip-group.types';
22
23
  export type * from './types/jl-chip.types';
23
24
  export type * from './types/jl-combobox.types';
24
25
  export type * from './types/jl-command-palette.types';
@@ -44,6 +45,7 @@ export type * from './types/jl-split-view.types';
44
45
  export type * from './types/jl-stat.types';
45
46
  export type * from './types/jl-steps.types';
46
47
  export type * from './types/jl-switch.types';
48
+ export type * from './types/jl-tab-bar.types';
47
49
  export type * from './types/jl-table.types';
48
50
  export type * from './types/jl-tabs.types';
49
51
  export type * from './types/jl-timeline.types';
@@ -83,6 +85,7 @@ export * from './molecules/jl-avatar-group';
83
85
  export * from './molecules/jl-breadcrumb';
84
86
  export * from './molecules/jl-calendar';
85
87
  export * from './molecules/jl-card';
88
+ export * from './molecules/jl-chip-group';
86
89
  export * from './molecules/jl-code-block';
87
90
  export * from './molecules/jl-combobox';
88
91
  export * from './molecules/jl-copy-button';
@@ -114,6 +117,7 @@ export * from './organisms/jl-flow';
114
117
  export * from './organisms/jl-navbar';
115
118
  export * from './organisms/jl-page-header';
116
119
  export * from './organisms/jl-side-nav';
120
+ export * from './organisms/jl-tab-bar';
117
121
  export * from './organisms/jl-table';
118
122
  export * from './organisms/jl-toaster';
119
123
  export * from './organisms/jl-tree';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { JlAvatarGroup } from "./molecules/jl-avatar-group.js";
34
34
  import { JlBreadcrumb } from "./molecules/jl-breadcrumb.js";
35
35
  import { JlCalendar } from "./molecules/jl-calendar.js";
36
36
  import { CARD_PADDINGS, JlCard } from "./molecules/jl-card.js";
37
+ import { JlChipGroup } from "./molecules/jl-chip-group.js";
37
38
  import { JlCopyButton } from "./molecules/jl-copy-button.js";
38
39
  import { CODE_LANGUAGES, JlCodeBlock } from "./molecules/jl-code-block.js";
39
40
  import { JlCombobox } from "./molecules/jl-combobox.js";
@@ -65,9 +66,10 @@ import { JlFlow } from "./organisms/jl-flow.js";
65
66
  import { JlNavbar } from "./organisms/jl-navbar.js";
66
67
  import { JlPageHeader } from "./organisms/jl-page-header.js";
67
68
  import { JlSideNav } from "./organisms/jl-side-nav.js";
69
+ import { JlTabBar } from "./organisms/jl-tab-bar.js";
68
70
  import { JlTable, TABLE_SORT_DIRECTIONS } from "./organisms/jl-table.js";
69
71
  import { JlToaster, TOASTER_PLACEMENTS, toast } from "./organisms/jl-toaster.js";
70
72
  import { JlTree } from "./organisms/jl-tree.js";
71
73
  import { JlAppShell } from "./templates/jl-app-shell.js";
72
74
  import { JlSplitView, SPLIT_ORIENTATIONS } from "./templates/jl-split-view.js";
73
- export { ACCORDION_VARIANTS, AVATAR_SHAPES, AVATAR_SIZES, AVATAR_STATUSES, BASE_STYLES, BUTTON_TYPES, BUTTON_VARIANTS, CARD_PADDINGS, CHIP_SIZES, CODE_LANGUAGES, COMMON_ICONS, CONFIRM_TONES, CONTROL_SIZES, CONTROL_STYLES, DIALOG_SIZES, DIVIDER_ALIGNS, DRAWER_PLACEMENTS, DRAWER_SIZES, EMPTY_SIZES, FIELD_ORIENTATIONS, FLOATING_STYLES, INPUT_TYPES, JlAccordion, JlAccordionItem, JlAlert, JlAppShell, JlAvatar, JlAvatarGroup, JlBadge, JlBreadcrumb, JlButton, JlCalendar, JlCard, JlCheckbox, JlChip, JlCodeBlock, JlCombobox, JlCommandPalette, JlConfirm, JlCopyButton, JlDatePicker, JlDialog, JlDivider, JlDrawer, JlElement, JlEmpty, JlField, JlFileDrop, JlFlow, JlFormControl, JlIcon, JlInput, JlKbd, JlLink, JlListItem, JlMenu, JlNavbar, JlNumberInput, JlPageHeader, JlPagination, JlPopover, JlProgress, JlProgressRing, JlRadioGroup, JlRating, JlSearch, JlSegmented, JlSelect, JlSideNav, JlSkeleton, JlSlider, JlSpinner, JlSplitView, JlStat, JlSteps, JlSwitch, JlTabPanel, JlTable, JlTabs, JlTagInput, JlTextarea, JlTimeline, JlToast, JlToaster, JlTooltip, JlTree, LINK_VARIANTS, PLACEMENTS, PROGRESS_SIZES, PROGRESS_TONES, RADIO_GROUP_ORIENTATIONS, RADIO_GROUP_VARIANTS, SKELETON_SHAPES, SPLIT_ORIENTATIONS, STAT_TRENDS, STEPS_ORIENTATIONS, SWITCH_SIZES, TABLE_SORT_DIRECTIONS, TAB_VARIANTS, TOASTER_PLACEMENTS, TOAST_TONES, TOKEN_STYLES, TONES, TONE_ICONS, TONE_ICON_FILLED, adoptTokens, clampedNumber, computePosition, confirmDialog, define, followAnchor, hasIcon, iconElement, iconNames, jsonOf, oneOf, placeFloating, registerIcons, toast };
75
+ export { ACCORDION_VARIANTS, AVATAR_SHAPES, AVATAR_SIZES, AVATAR_STATUSES, BASE_STYLES, BUTTON_TYPES, BUTTON_VARIANTS, CARD_PADDINGS, CHIP_SIZES, CODE_LANGUAGES, COMMON_ICONS, CONFIRM_TONES, CONTROL_SIZES, CONTROL_STYLES, DIALOG_SIZES, DIVIDER_ALIGNS, DRAWER_PLACEMENTS, DRAWER_SIZES, EMPTY_SIZES, FIELD_ORIENTATIONS, FLOATING_STYLES, INPUT_TYPES, JlAccordion, JlAccordionItem, JlAlert, JlAppShell, JlAvatar, JlAvatarGroup, JlBadge, JlBreadcrumb, JlButton, JlCalendar, JlCard, JlCheckbox, JlChip, JlChipGroup, JlCodeBlock, JlCombobox, JlCommandPalette, JlConfirm, JlCopyButton, JlDatePicker, JlDialog, JlDivider, JlDrawer, JlElement, JlEmpty, JlField, JlFileDrop, JlFlow, JlFormControl, JlIcon, JlInput, JlKbd, JlLink, JlListItem, JlMenu, JlNavbar, JlNumberInput, JlPageHeader, JlPagination, JlPopover, JlProgress, JlProgressRing, JlRadioGroup, JlRating, JlSearch, JlSegmented, JlSelect, JlSideNav, JlSkeleton, JlSlider, JlSpinner, JlSplitView, JlStat, JlSteps, JlSwitch, JlTabBar, JlTabPanel, JlTable, JlTabs, JlTagInput, JlTextarea, JlTimeline, JlToast, JlToaster, JlTooltip, JlTree, LINK_VARIANTS, PLACEMENTS, PROGRESS_SIZES, PROGRESS_TONES, RADIO_GROUP_ORIENTATIONS, RADIO_GROUP_VARIANTS, SKELETON_SHAPES, SPLIT_ORIENTATIONS, STAT_TRENDS, STEPS_ORIENTATIONS, SWITCH_SIZES, TABLE_SORT_DIRECTIONS, TAB_VARIANTS, TOASTER_PLACEMENTS, TOAST_TONES, TOKEN_STYLES, TONES, TONE_ICONS, TONE_ICON_FILLED, adoptTokens, clampedNumber, computePosition, confirmDialog, define, followAnchor, hasIcon, iconElement, iconNames, jsonOf, oneOf, placeFloating, registerIcons, toast };
@@ -7,3 +7,8 @@ export declare const oneOf: <Value extends string>(allowed: readonly Value[], fa
7
7
  export declare const jsonOf: <Value>(fallback: Value) => (value: string | null) => Value;
8
8
  /** A number attribute kept within bounds; a missing or unreadable one gives the fallback. */
9
9
  export declare const clampedNumber: (fallback: number, min: number, max: number) => (value: string | null) => number;
10
+ /**
11
+ * An attribute holding a list of strings, as HTML writes it: a JSON list (`'["a","b"]'`), or one plain value
12
+ * (`open`) for the common case of a single one. Empty or unreadable gives an empty list.
13
+ */
14
+ export declare const stringList: (value: string | null) => string[];
@@ -19,4 +19,16 @@ var clampedNumber = (fallback, min, max) => (value) => {
19
19
  if (Number.isNaN(parsed)) return fallback;
20
20
  return Math.min(max, Math.max(min, parsed));
21
21
  };
22
- export { clampedNumber, jsonOf, oneOf };
22
+ var LIST_START = "[";
23
+ /**
24
+ * An attribute holding a list of strings, as HTML writes it: a JSON list (`'["a","b"]'`), or one plain value
25
+ * (`open`) for the common case of a single one. Empty or unreadable gives an empty list.
26
+ */
27
+ var stringList = (value) => {
28
+ const text = value?.trim() ?? "";
29
+ if (text === "") return [];
30
+ if (!text.startsWith(LIST_START)) return [text];
31
+ const parsed = jsonOf([])(text);
32
+ return Array.isArray(parsed) ? parsed.map(String) : [];
33
+ };
34
+ export { clampedNumber, jsonOf, oneOf, stringList };
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Gives the document the design tokens, once. They live in the `jlib` cascade layer, so the page overrides any of
3
- * them with a plain rule: `:root { --jl-accent: #7c3aed; }`.
3
+ * them with a plain rule: `:root { --jl-accent: #7c3aed; }`. Where the document takes no constructed stylesheet, a
4
+ * style element in its head holds them instead.
4
5
  */
5
6
  export declare function adoptTokens(): void;
@@ -1,15 +1,24 @@
1
1
  import { TOKEN_STYLES } from "../styles/shared.js";
2
2
  // src/lib/tokens.ts
3
- var adopted = { sheet: null };
3
+ var adopted = { done: false };
4
+ /** Whether the document takes constructed stylesheets: jsdom, which runs many apps' unit tests, does not. */
5
+ var takesSheets = () => typeof CSSStyleSheet !== "undefined" && typeof CSSStyleSheet.prototype.replaceSync === "function" && Array.isArray(document.adoptedStyleSheets);
4
6
  /**
5
7
  * Gives the document the design tokens, once. They live in the `jlib` cascade layer, so the page overrides any of
6
- * them with a plain rule: `:root { --jl-accent: #7c3aed; }`.
8
+ * them with a plain rule: `:root { --jl-accent: #7c3aed; }`. Where the document takes no constructed stylesheet, a
9
+ * style element in its head holds them instead.
7
10
  */
8
11
  function adoptTokens() {
9
- if (adopted.sheet !== null || typeof document === "undefined") return;
12
+ if (adopted.done || typeof document === "undefined") return;
13
+ adopted.done = true;
14
+ if (!takesSheets()) {
15
+ const style = document.createElement("style");
16
+ style.textContent = TOKEN_STYLES;
17
+ document.head.append(style);
18
+ return;
19
+ }
10
20
  const sheet = new CSSStyleSheet();
11
21
  sheet.replaceSync(TOKEN_STYLES);
12
22
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
13
- adopted.sheet = sheet;
14
23
  }
15
24
  export { adoptTokens };
@@ -1,8 +1,9 @@
1
1
  import { JlElement } from '../base/jl-element';
2
2
  import type { Tone } from '../types/options.types';
3
3
  /**
4
- * A message in the flow of the page, coloured by its tone: a tip, a success, a warning, an error. The text goes
5
- * in the default slot, buttons in `slot="actions"`. A `dismissible` alert has an × that hides it.
4
+ * A message in the flow of the page, coloured by its tone: a tip, a success, a warning, an error. The text is the
5
+ * `description` or goes in the default slot (for links and markup), buttons in `slot="actions"`. A `dismissible`
6
+ * alert has an × that hides it.
6
7
  */
7
8
  export declare class JlAlert extends JlElement {
8
9
  static readonly selector = "jl-alert";
@@ -17,6 +18,11 @@ export declare class JlAlert extends JlElement {
17
18
  get(): string;
18
19
  set(setter: string | ((prevState: string) => string)): void;
19
20
  }>>;
21
+ /** The message as plain text, for pages that set it from code; richer content goes in the default slot. */
22
+ readonly description: Readonly<Readonly<{
23
+ get(): string;
24
+ set(setter: string | ((prevState: string) => string)): void;
25
+ }>>;
20
26
  /** An icon name in place of the tone's own; `none` shows no icon. */
21
27
  readonly icon: Readonly<Readonly<{
22
28
  get(): string;
@@ -33,7 +39,8 @@ export declare class JlAlert extends JlElement {
33
39
  templateHtml(): Node;
34
40
  templateCss(): string;
35
41
  onConnect(): void;
36
- private renderActions;
42
+ /** Empty parts take no room: an alert with only a description has no blank line under it. */
43
+ private renderSlots;
37
44
  /** The tone's own icon is filled, as a toast's is; an icon of the page's stays as it is drawn. */
38
45
  private renderIcon;
39
46
  }
@@ -31,8 +31,9 @@ var TONE_ROLES = {
31
31
  warn: "alert"
32
32
  };
33
33
  /**
34
- * A message in the flow of the page, coloured by its tone: a tip, a success, a warning, an error. The text goes
35
- * in the default slot, buttons in `slot="actions"`. A `dismissible` alert has an × that hides it.
34
+ * A message in the flow of the page, coloured by its tone: a tip, a success, a warning, an error. The text is the
35
+ * `description` or goes in the default slot (for links and markup), buttons in `slot="actions"`. A `dismissible`
36
+ * alert has an × that hides it.
36
37
  */
37
38
  var JlAlert = class extends JlElement {
38
39
  static selector = "jl-alert";
@@ -49,6 +50,11 @@ var JlAlert = class extends JlElement {
49
50
  this._refs.heading.textContent = value;
50
51
  this._refs.heading.hidden = value === "";
51
52
  }, { attribute: "heading" });
53
+ /** The message as plain text, for pages that set it from code; richer content goes in the default slot. */
54
+ description = this.useChange("", (value) => {
55
+ this._refs.description.textContent = value;
56
+ this._refs.description.hidden = value === "";
57
+ }, { attribute: "description" });
52
58
  /** An icon name in place of the tone's own; `none` shows no icon. */
53
59
  icon = this.useChange("", () => this.renderIcon(), { attribute: "icon" });
54
60
  /** Shows an × that hides the alert and fires `dismiss`. */
@@ -61,8 +67,11 @@ var JlAlert = class extends JlElement {
61
67
  actionsSlot: ref("slot[name=\"actions\"]"),
62
68
  alert: ref(".alert"),
63
69
  close: ref(".close"),
70
+ description: ref(".description"),
64
71
  heading: ref(".heading"),
65
- icon: ref(".icon")
72
+ icon: ref(".icon"),
73
+ text: ref(".text"),
74
+ textSlot: ref("slot:not([name])")
66
75
  }));
67
76
  /** Hides the alert as its × does, and fires `dismiss`. */
68
77
  dismiss() {
@@ -86,8 +95,13 @@ var JlAlert = class extends JlElement {
86
95
  class: "heading",
87
96
  hidden: true
88
97
  }),
98
+ /* @__PURE__ */ jsx("p", {
99
+ class: "description",
100
+ hidden: true
101
+ }),
89
102
  /* @__PURE__ */ jsx("div", {
90
103
  class: "text",
104
+ hidden: true,
91
105
  children: /* @__PURE__ */ jsx("slot", {})
92
106
  }),
93
107
  /* @__PURE__ */ jsx("div", {
@@ -113,10 +127,13 @@ var JlAlert = class extends JlElement {
113
127
  }
114
128
  onConnect() {
115
129
  this._refs.close.events.register("press", () => this.dismiss());
116
- this.renderActions();
117
- this.on(this._refs.actionsSlot, "slotchange", () => this.renderActions());
130
+ this.renderSlots();
131
+ this.on(this._refs.actionsSlot, "slotchange", () => this.renderSlots());
132
+ this.on(this._refs.textSlot, "slotchange", () => this.renderSlots());
118
133
  }
119
- renderActions() {
134
+ /** Empty parts take no room: an alert with only a description has no blank line under it. */
135
+ renderSlots() {
136
+ this._refs.text.hidden = !hasSlotted(this._refs.textSlot);
120
137
  this._refs.actions.hidden = !hasSlotted(this._refs.actionsSlot);
121
138
  }
122
139
  /** The tone's own icon is filled, as a toast's is; an icon of the page's stays as it is drawn. */
@@ -0,0 +1,3 @@
1
+ // src/molecules/jl-chip-group.css?raw
2
+ var jl_chip_group_default = ":host {\n display: block;\n min-width: 0;\n --height: 28px;\n --pad: 10px;\n --font-size: var(--jl-font-sm);\n}\n\n:host([size=\"sm\"]) {\n --height: 24px;\n --pad: 8px;\n --font-size: var(--jl-font-xs);\n}\n\n/* Where a thumb presses it. */\n:host([size=\"lg\"]) {\n --height: 36px;\n --pad: 14px;\n --font-size: var(--jl-font-md);\n}\n\n/*\n * One row that scrolls sideways on a narrow screen. The padding keeps the focus ring inside what the scroller\n * clips; the negative margin keeps the chips aligned with the content around.\n */\n.group {\n display: flex;\n gap: 8px;\n min-width: 0;\n margin: -3px;\n padding: 3px;\n overflow-x: auto;\n scrollbar-width: none;\n contain: inline-size;\n}\n\n.group::-webkit-scrollbar {\n display: none;\n}\n\n:host([wrap]) .group {\n flex-wrap: wrap;\n overflow: visible;\n contain: none;\n}\n\n.chip {\n display: inline-flex;\n flex: none;\n align-items: center;\n gap: 6px;\n max-width: 100%;\n height: var(--height);\n padding: 0 var(--pad);\n border: 1px solid var(--jl-line-strong);\n border-radius: var(--jl-radius-pill);\n background: var(--jl-surface);\n color: var(--jl-text-2);\n font-size: var(--font-size);\n font-weight: 600;\n line-height: 1;\n white-space: nowrap;\n cursor: pointer;\n transition:\n background var(--jl-fast) var(--jl-ease),\n border-color var(--jl-fast) var(--jl-ease),\n color var(--jl-fast) var(--jl-ease);\n}\n\n.chip:hover:not(:disabled) {\n background: var(--jl-surface-2);\n}\n\n.chip.selected,\n.chip.selected:hover:not(:disabled) {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent);\n}\n\n.chip:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n/* A line box taller than the chip's own line-height of 1: the accents of É, Â, Ö stay inside what overflow clips. */\n.label {\n overflow: hidden;\n line-height: 1.4;\n text-overflow: ellipsis;\n}\n\n.badge {\n min-width: 18px;\n margin-right: -4px;\n padding: 0 6px;\n border-radius: var(--jl-radius-pill);\n background: var(--jl-surface-3);\n color: var(--jl-muted);\n font-size: var(--jl-font-xs);\n font-variant-numeric: tabular-nums;\n font-weight: 650;\n line-height: 18px;\n text-align: center;\n}\n\n.chip.selected .badge {\n background: color-mix(in srgb, var(--jl-accent) 14%, transparent);\n color: var(--jl-accent);\n}\n";
3
+ export { jl_chip_group_default as default };
@@ -0,0 +1,81 @@
1
+ import { JlFormControl } from '../base/jl-form-control';
2
+ import type { ChipGroupOption } from '../types/jl-chip-group.types';
3
+ import type { ControlSize } from '../types/options.types';
4
+ /**
5
+ * A row of chips to choose from: a status to filter by, a category, a reason. One choice by default, as radios
6
+ * (choosing the chosen chip again keeps it); `multiple` lets people turn each chip on and off. On a narrow screen
7
+ * the row scrolls sideways; `wrap` lets it go to the next line instead. `change` fires with the chosen values.
8
+ */
9
+ export declare class JlChipGroup extends JlFormControl {
10
+ static readonly selector = "jl-chip-group";
11
+ readonly events: Readonly<import("@jadis/core").UseEventsHandler<{
12
+ change: string[];
13
+ }>>;
14
+ readonly options: Readonly<Readonly<{
15
+ get(): ChipGroupOption[];
16
+ set(setter: ChipGroupOption[] | ((prevState: ChipGroupOption[]) => ChipGroupOption[])): void;
17
+ }>>;
18
+ /** The chosen values, in the options' order; with one choice, a list of at most one. */
19
+ readonly value: Readonly<Readonly<{
20
+ get(): string[];
21
+ set(setter: string[] | ((prevState: string[]) => string[])): void;
22
+ }>>;
23
+ /** Several chips can be chosen, each turned on and off on its own. */
24
+ readonly multiple: Readonly<Readonly<{
25
+ get(): boolean;
26
+ set(setter: boolean | ((prevState: boolean) => boolean)): void;
27
+ }>>;
28
+ /** Goes to the next line when the chips do not fit, instead of scrolling sideways. */
29
+ readonly wrap: Readonly<Readonly<{
30
+ get(): boolean;
31
+ set(setter: boolean | ((prevState: boolean) => boolean)): void;
32
+ }>>;
33
+ readonly size: Readonly<Readonly<{
34
+ get(): ControlSize;
35
+ set(setter: ((prevState: ControlSize) => ControlSize) | ControlSize): void;
36
+ }>>;
37
+ /** The group's name for screen readers, when no visible label names it. */
38
+ readonly label: Readonly<Readonly<{
39
+ get(): string;
40
+ set(setter: string | ((prevState: string) => string)): void;
41
+ }>>;
42
+ /** A form refuses to submit while nothing is chosen. */
43
+ readonly required: Readonly<Readonly<{
44
+ get(): boolean;
45
+ set(setter: boolean | ((prevState: boolean) => boolean)): void;
46
+ }>>;
47
+ readonly disabled: Readonly<Readonly<{
48
+ get(): boolean;
49
+ set(setter: boolean | ((prevState: boolean) => boolean)): void;
50
+ }>>;
51
+ /** The chip that holds the Tab stop with several choices: the last one people reached. */
52
+ private readonly _state;
53
+ private readonly _nameWatch;
54
+ private readonly _refs;
55
+ /** Focuses the chip that holds the Tab stop. */
56
+ focus(options?: FocusOptions): void;
57
+ templateHtml(): Node;
58
+ templateCss(): string;
59
+ onConnect(): void;
60
+ onDisconnect(): void;
61
+ protected resetValue(): void;
62
+ /** The browser calls it for a disabled fieldset and for the element's own `disabled`, even before it renders. */
63
+ protected setDisabledByForm(disabled: boolean): void;
64
+ private chips;
65
+ /** What is chosen: with one choice, only the first value counts. */
66
+ private chosen;
67
+ private isDisabled;
68
+ /** One choice: the chip becomes it. Several: the chip turns on or off, the values keeping the options' order. */
69
+ private press;
70
+ private commit;
71
+ private onKeydown;
72
+ private renderOptions;
73
+ private chip;
74
+ /** Radios with one choice, toggle buttons with several; each chosen chip shows its icon or a check. */
75
+ private renderValue;
76
+ /** Tab reaches one chip: the chosen one with one choice, the last one reached with several, else the first. */
77
+ private renderTabStop;
78
+ /** One choice gives one entry; several give one entry each under the field's name, as checkboxes would. */
79
+ private renderFormValue;
80
+ private renderValidity;
81
+ }
@@ -0,0 +1,259 @@
1
+ import { BASE_STYLES } from "../styles/shared.js";
2
+ import { JlFormControl } from "../base/jl-form-control.js";
3
+ import { jsonOf, oneOf, stringList } from "../lib/attributes.js";
4
+ import { registerIcons } from "../lib/icons.js";
5
+ import { CONTROL_SIZES } from "../lib/options.js";
6
+ import { setOrRemove } from "../lib/dom.js";
7
+ import { JlIcon } from "../atoms/jl-icon.js";
8
+ import { rovingIndex } from "../lib/roving-index.js";
9
+ import jl_chip_group_default from "./jl-chip-group.css_raw.js";
10
+ import { css } from "@jadis/core";
11
+ import { Check } from "lucide";
12
+ import { jsx, jsxs } from "@jadis/core/jsx-runtime";
13
+ // src/molecules/jl-chip-group.tsx
14
+ registerIcons({ check: Check });
15
+ var ICON_SIZES = {
16
+ lg: 16,
17
+ md: 14,
18
+ sm: 12
19
+ };
20
+ var SELECTED_ICON = "check";
21
+ var CHIP_SELECTOR = ".chip";
22
+ var VALUE_MISSING = "Please select one of these options.";
23
+ /** The keys that move between the chips; with one choice, the choice moves too, as with native radios. */
24
+ var MOVES = /* @__PURE__ */ new Map([
25
+ ["ArrowDown", "next"],
26
+ ["ArrowLeft", "previous"],
27
+ ["ArrowRight", "next"],
28
+ ["ArrowUp", "previous"],
29
+ ["End", "last"],
30
+ ["Home", "first"]
31
+ ]);
32
+ /** A JSON attribute that should hold a list of options; anything else gives none. */
33
+ var parseOptions = (value) => {
34
+ const parsed = jsonOf([])(value);
35
+ return Array.isArray(parsed) ? parsed : [];
36
+ };
37
+ /**
38
+ * A row of chips to choose from: a status to filter by, a category, a reason. One choice by default, as radios
39
+ * (choosing the chosen chip again keeps it); `multiple` lets people turn each chip on and off. On a narrow screen
40
+ * the row scrolls sideways; `wrap` lets it go to the next line instead. `change` fires with the chosen values.
41
+ */
42
+ var JlChipGroup = class extends JlFormControl {
43
+ static selector = "jl-chip-group";
44
+ events = this.useEvents();
45
+ options = this.useChange([], () => this.renderOptions(), {
46
+ attribute: "options",
47
+ parse: parseOptions
48
+ });
49
+ /** The chosen values, in the options' order; with one choice, a list of at most one. */
50
+ value = this.useChange([], () => this.renderValue(), {
51
+ attribute: "value",
52
+ parse: stringList
53
+ });
54
+ /** Several chips can be chosen, each turned on and off on its own. */
55
+ multiple = this.useChange(false, (value) => {
56
+ this.reflect("multiple", value);
57
+ this.renderValue();
58
+ }, { attribute: "multiple" });
59
+ /** Goes to the next line when the chips do not fit, instead of scrolling sideways. */
60
+ wrap = this.useChange(false, (value) => this.reflect("wrap", value), { attribute: "wrap" });
61
+ size = this.useChange("md", (value) => {
62
+ this.reflect("size", value);
63
+ this.renderOptions();
64
+ }, {
65
+ attribute: "size",
66
+ parse: oneOf(CONTROL_SIZES, "md")
67
+ });
68
+ /** The group's name for screen readers, when no visible label names it. */
69
+ label = this.useChange("", (value) => setOrRemove(this._refs.group, "aria-label", value), { attribute: "label" });
70
+ /** A form refuses to submit while nothing is chosen. */
71
+ required = this.useChange(false, (value) => {
72
+ this.reflect("required", value);
73
+ this._refs.group.toggleAttribute("aria-required", value);
74
+ this.renderValidity();
75
+ }, { attribute: "required" });
76
+ disabled = this.useChange(false, (value) => {
77
+ this.reflect("disabled", value);
78
+ this.renderValue();
79
+ }, { attribute: "disabled" });
80
+ /** The chip that holds the Tab stop with several choices: the last one people reached. */
81
+ _state = {
82
+ disabledByForm: false,
83
+ stop: ""
84
+ };
85
+ _nameWatch = new MutationObserver(() => this.renderFormValue());
86
+ _refs = this.useRefs((ref) => ({ group: ref(".group") }));
87
+ /** Focuses the chip that holds the Tab stop. */
88
+ focus(options) {
89
+ this.chips().find((chip) => chip.tabIndex === 0)?.focus(options);
90
+ }
91
+ templateHtml() {
92
+ return /* @__PURE__ */ jsx("div", {
93
+ class: "group",
94
+ role: "radiogroup",
95
+ attrs: { part: "base" }
96
+ });
97
+ }
98
+ templateCss() {
99
+ return css`${BASE_STYLES}${jl_chip_group_default}`;
100
+ }
101
+ onConnect() {
102
+ this._nameWatch.observe(this, {
103
+ attributeFilter: ["name"],
104
+ attributes: true
105
+ });
106
+ this.renderFormValue();
107
+ this.on(this._refs.group, "click", (event) => {
108
+ const chip = event.target.closest(CHIP_SELECTOR);
109
+ if (chip !== null && !chip.disabled) this.press(chip);
110
+ });
111
+ this.on(this._refs.group, "keydown", (event) => this.onKeydown(event));
112
+ }
113
+ onDisconnect() {
114
+ this._nameWatch.disconnect();
115
+ }
116
+ resetValue() {
117
+ this.value.set(stringList(this.getAttribute("value")));
118
+ }
119
+ /** The browser calls it for a disabled fieldset and for the element's own `disabled`, even before it renders. */
120
+ setDisabledByForm(disabled) {
121
+ this._state.disabledByForm = disabled;
122
+ this.whenRendered(() => this.renderValue());
123
+ }
124
+ chips() {
125
+ return [...this._refs.group.querySelectorAll(CHIP_SELECTOR)];
126
+ }
127
+ /** What is chosen: with one choice, only the first value counts. */
128
+ chosen() {
129
+ const values = this.value.get();
130
+ return this.multiple.get() ? values : values.slice(0, 1);
131
+ }
132
+ isDisabled() {
133
+ return this.disabled.get() || this._state.disabledByForm;
134
+ }
135
+ /** One choice: the chip becomes it. Several: the chip turns on or off, the values keeping the options' order. */
136
+ press(chip) {
137
+ const value = chip.dataset.value ?? "";
138
+ this._state.stop = value;
139
+ const chosen = this.chosen();
140
+ if (!this.multiple.get()) {
141
+ if (chosen[0] === value) {
142
+ this.renderTabStop();
143
+ return;
144
+ }
145
+ this.commit([value]);
146
+ return;
147
+ }
148
+ const next = new Set(chosen);
149
+ if (next.has(value)) next.delete(value);
150
+ else next.add(value);
151
+ this.commit(this.options.get().flatMap((option) => next.has(option.value) ? [option.value] : []));
152
+ }
153
+ commit(values) {
154
+ this.value.set(values);
155
+ this.events.emit("change", values);
156
+ }
157
+ onKeydown(event) {
158
+ const move = MOVES.get(event.key);
159
+ const chips = this.chips();
160
+ const from = chips.indexOf(event.target);
161
+ if (move === void 0 || from === -1) return;
162
+ event.preventDefault();
163
+ const target = chips[rovingIndex(chips.map((chip) => chip.disabled), from, move)];
164
+ if (target === void 0) return;
165
+ target.focus();
166
+ if (this.multiple.get()) {
167
+ this._state.stop = target.dataset.value ?? "";
168
+ this.renderTabStop();
169
+ return;
170
+ }
171
+ this.press(target);
172
+ }
173
+ renderOptions() {
174
+ const iconSize = ICON_SIZES[this.size.get()];
175
+ this._refs.group.replaceChildren(...this.options.get().map((option) => this.chip(option, iconSize)));
176
+ this.renderValue();
177
+ }
178
+ chip(option, iconSize) {
179
+ const badge = option.badge === void 0 ? "" : String(option.badge);
180
+ return /* @__PURE__ */ jsxs("button", {
181
+ type: "button",
182
+ class: "chip",
183
+ tabIndex: -1,
184
+ "data-value": option.value,
185
+ "data-icon": option.icon,
186
+ "data-disabled": option.disabled === true ? "" : void 0,
187
+ children: [
188
+ /* @__PURE__ */ jsx(JlIcon, {
189
+ class: "icon",
190
+ size: iconSize,
191
+ hidden: true
192
+ }),
193
+ /* @__PURE__ */ jsx("span", {
194
+ class: "label",
195
+ children: option.label
196
+ }),
197
+ badge === "" ? "" : /* @__PURE__ */ jsx("span", {
198
+ class: "badge",
199
+ children: badge
200
+ })
201
+ ]
202
+ });
203
+ }
204
+ /** Radios with one choice, toggle buttons with several; each chosen chip shows its icon or a check. */
205
+ renderValue() {
206
+ const chosen = new Set(this.chosen());
207
+ const multiple = this.multiple.get();
208
+ const groupDisabled = this.isDisabled();
209
+ this._refs.group.setAttribute("role", multiple ? "group" : "radiogroup");
210
+ this._refs.group.toggleAttribute("aria-disabled", groupDisabled);
211
+ for (const chip of this.chips()) {
212
+ const selected = chosen.has(chip.dataset.value ?? "");
213
+ chip.disabled = groupDisabled || chip.dataset.disabled !== void 0;
214
+ setOrRemove(chip, "role", multiple ? "" : "radio");
215
+ setOrRemove(chip, "aria-checked", multiple ? "" : String(selected));
216
+ setOrRemove(chip, "aria-pressed", multiple ? String(selected) : "");
217
+ chip.classList.toggle("selected", selected);
218
+ const icon = chip.querySelector(".icon");
219
+ const name = chip.dataset.icon ?? (selected ? SELECTED_ICON : "");
220
+ icon?.name.set(name);
221
+ if (icon !== null) icon.hidden = name === "";
222
+ }
223
+ this.renderTabStop();
224
+ this.renderFormValue();
225
+ this.renderValidity();
226
+ }
227
+ /** Tab reaches one chip: the chosen one with one choice, the last one reached with several, else the first. */
228
+ renderTabStop() {
229
+ const enabled = this.chips().filter((chip) => !chip.disabled);
230
+ const chosen = this.chosen();
231
+ const stop = (this.multiple.get() ? [this._state.stop, ...chosen] : chosen).map((value) => enabled.find((chip) => chip.dataset.value === value)).find((chip) => chip !== void 0) ?? enabled[0];
232
+ for (const chip of this.chips()) chip.tabIndex = chip === stop ? 0 : -1;
233
+ }
234
+ /** One choice gives one entry; several give one entry each under the field's name, as checkboxes would. */
235
+ renderFormValue() {
236
+ const chosen = this.chosen();
237
+ if (!this.multiple.get()) {
238
+ this.internals.setFormValue(chosen[0] ?? null);
239
+ return;
240
+ }
241
+ const name = this.getAttribute("name") ?? "";
242
+ if (name === "") {
243
+ this.internals.setFormValue(null);
244
+ return;
245
+ }
246
+ const data = new FormData();
247
+ for (const value of chosen) data.append(name, value);
248
+ this.internals.setFormValue(data);
249
+ }
250
+ renderValidity() {
251
+ if (!(this.required.get() && this.chosen().length === 0)) {
252
+ this.internals.setValidity({});
253
+ return;
254
+ }
255
+ this.internals.setValidity({ valueMissing: true }, VALUE_MISSING, this.chips()[0] ?? this._refs.group);
256
+ }
257
+ };
258
+ JlChipGroup.register();
259
+ export { JlChipGroup };
@@ -1,3 +1,3 @@
1
1
  // src/molecules/jl-tag-input.css?raw
2
- var jl_tag_input_default = ":host {\n display: block;\n /* As a grid or flex item, the field may be narrower than its text field would like. */\n min-width: 0;\n --chip-height: calc(var(--height, var(--jl-control-md)) - 12px);\n}\n\n.control {\n flex-wrap: wrap;\n gap: 4px;\n padding: 4px 6px;\n cursor: text;\n}\n\n/* The chips are items of the frame's own flow, so the field follows the last one on its line. */\n.tags {\n display: contents;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.tag {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n max-width: 100%;\n height: var(--chip-height);\n padding: 0 3px 0 8px;\n border: 1px solid var(--jl-line);\n border-radius: var(--jl-radius-xs);\n background: var(--jl-surface-2);\n color: var(--jl-text);\n font-size: var(--jl-font-sm);\n line-height: 1;\n cursor: default;\n}\n\n:host([size=\"sm\"]) .tag {\n padding-left: 6px;\n font-size: var(--jl-font-xs);\n}\n\n.tag.flash {\n animation: flash 600ms var(--jl-ease);\n}\n\n.tag-text {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tag-remove {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 4px;\n background: none;\n color: var(--jl-faint);\n cursor: pointer;\n}\n\n.tag-remove:hover,\n.tag-remove:focus-visible {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n.native {\n flex: 1 0 80px;\n height: calc(var(--height, var(--jl-control-md)) - 10px);\n padding-left: 6px;\n}\n\n:host([disabled]) .tag {\n padding-right: 8px;\n color: var(--jl-muted);\n}\n\n.popup {\n min-width: 180px;\n padding: 4px;\n}\n\n.listbox {\n position: relative;\n max-height: 240px;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n list-style: none;\n}\n\n.option {\n padding: 6px 10px;\n border-radius: var(--jl-radius-xs);\n color: var(--jl-text);\n font-size: var(--jl-font-md);\n line-height: 1.4;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n}\n\n.option.active,\n.option:hover {\n background: var(--jl-surface-3);\n}\n\nmark {\n background: none;\n color: var(--jl-accent);\n font-weight: 700;\n}\n\n@keyframes flash {\n 30% {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent-strong);\n }\n}\n";
2
+ var jl_tag_input_default = ":host {\n display: block;\n /* As a grid or flex item, the field may be narrower than its text field would like. */\n min-width: 0;\n --chip-height: calc(var(--height, var(--jl-control-md)) - 12px);\n}\n\n.control {\n flex-wrap: wrap;\n gap: 4px;\n padding: 4px 6px;\n cursor: text;\n}\n\n/* The chips are items of the frame's own flow, so the field follows the last one on its line. */\n.tags {\n display: contents;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.tag {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n max-width: 100%;\n height: var(--chip-height);\n padding: 0 3px 0 8px;\n border: 1px solid var(--jl-line);\n border-radius: var(--jl-radius-xs);\n background: var(--jl-surface-2);\n color: var(--jl-text);\n font-size: var(--jl-font-sm);\n line-height: 1;\n cursor: default;\n}\n\n:host([size=\"sm\"]) .tag {\n padding-left: 6px;\n font-size: var(--jl-font-xs);\n}\n\n.tag.flash {\n animation: flash 600ms var(--jl-ease);\n}\n\n/* A line box taller than the tag's own line-height of 1: the accents of É, Â, Ö stay inside what overflow clips. */\n.tag-text {\n overflow: hidden;\n line-height: 1.4;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.tag-remove {\n display: inline-flex;\n flex: none;\n align-items: center;\n justify-content: center;\n width: 18px;\n height: 18px;\n padding: 0;\n border: none;\n border-radius: 4px;\n background: none;\n color: var(--jl-faint);\n cursor: pointer;\n}\n\n.tag-remove:hover,\n.tag-remove:focus-visible {\n background: var(--jl-surface-3);\n color: var(--jl-text);\n}\n\n.native {\n flex: 1 0 80px;\n height: calc(var(--height, var(--jl-control-md)) - 10px);\n padding-left: 6px;\n}\n\n:host([disabled]) .tag {\n padding-right: 8px;\n color: var(--jl-muted);\n}\n\n.popup {\n min-width: 180px;\n padding: 4px;\n}\n\n.listbox {\n position: relative;\n max-height: 240px;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n list-style: none;\n}\n\n.option {\n padding: 6px 10px;\n border-radius: var(--jl-radius-xs);\n color: var(--jl-text);\n font-size: var(--jl-font-md);\n line-height: 1.4;\n white-space: nowrap;\n cursor: pointer;\n user-select: none;\n}\n\n.option.active,\n.option:hover {\n background: var(--jl-surface-3);\n}\n\nmark {\n background: none;\n color: var(--jl-accent);\n font-weight: 700;\n}\n\n@keyframes flash {\n 30% {\n border-color: var(--jl-accent-line);\n background: var(--jl-accent-soft);\n color: var(--jl-accent-strong);\n }\n}\n";
3
3
  export { jl_tag_input_default as default };
@@ -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
+ };
@@ -0,0 +1,11 @@
1
+ /** One place in a bottom tab bar: an icon over a short label. */
2
+ export type TabBarItem = {
3
+ id: string;
4
+ label: string;
5
+ /** An icon name: the tab bar draws one over every label. */
6
+ icon: string;
7
+ /** A link to follow; without it the item is a button that only fires `select`. */
8
+ href?: string;
9
+ /** A count or a short word over the icon: `3`, `New`. Counts above 99 show as `99+`. */
10
+ badge?: string | number;
11
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syware/jlib",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A tree-shakeable component library on Jadis and native Web Components, organised in atomic design",
5
5
  "keywords": [
6
6
  "web-components",