@hypermedia-components/core 0.1.2 → 0.1.3

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.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Install the editable-code behavior on the given root.
3
+ *
4
+ * Enhances every `.hc-code[data-editable]` once and re-scans subtrees
5
+ * delivered by htmx (`htmx:load`). Repeated calls on the same root return the
6
+ * same uninstaller.
7
+ *
8
+ * @param {Document|Element} [root=document]
9
+ * The scope to scan. Defaults to the global document when available.
10
+ * @returns {() => void} an idempotent uninstaller that removes the synced
11
+ * gutters and listeners it added.
12
+ */
13
+ export function installCodeEditor(root?: Document | Element): () => void;
@@ -0,0 +1,121 @@
1
+ // installCodeEditor — upgrade an editable `hc-code` field with a synced
2
+ // line-number gutter (issue #255).
3
+ //
4
+ // <div class="hc-code" data-editable data-gutter="line-numbers">
5
+ // <textarea class="hc-code__input" name="content" spellcheck="false">SELECT 1</textarea>
6
+ // </div>
7
+ //
8
+ // The value lives in a real <textarea name>, so it submits in forms, works
9
+ // with htmx (hx-post / hx-include / hx-vals), and degrades to a plain
10
+ // monospace textarea when this script is absent. When `data-gutter="line-numbers"`
11
+ // is set, the behavior inserts a `.hc-code__gutter` element before the
12
+ // textarea and keeps it in sync: it re-numbers on input and matches the
13
+ // textarea's vertical scroll. To keep the numbers aligned with the lines it
14
+ // sets the textarea to not soft-wrap (`wrap="off"`), so long lines scroll
15
+ // horizontally rather than pushing the numbers out of step.
16
+ //
17
+ // Syntax highlighting is out of scope (a CSP-safe overlay is a possible
18
+ // follow-up). installCodeEditor(root = document) is idempotent and returns an
19
+ // uninstaller; fields swapped in by htmx are enhanced on `htmx:load`.
20
+
21
+ const INSTALL_KEY = '__hcCodeEditorUninstall';
22
+
23
+ function lineNumbers(count) {
24
+ let out = '1';
25
+ for (let i = 2; i <= count; i += 1) out += '\n' + i;
26
+ return out;
27
+ }
28
+
29
+ function enhance(container) {
30
+ const textarea = container.querySelector('.hc-code__input');
31
+ if (!textarea) return null;
32
+ if (container.dataset.gutter !== 'line-numbers') return () => {};
33
+ if (container.querySelector('.hc-code__gutter')) return () => {};
34
+
35
+ // Keep line numbers aligned: a soft-wrapped line would span several rows
36
+ // while the gutter counts one. Horizontal scroll instead.
37
+ const prevWrap = textarea.getAttribute('wrap');
38
+ textarea.setAttribute('wrap', 'off');
39
+
40
+ const gutter = container.ownerDocument.createElement('div');
41
+ gutter.className = 'hc-code__gutter';
42
+ gutter.setAttribute('aria-hidden', 'true');
43
+ container.insertBefore(gutter, textarea);
44
+
45
+ let lastCount = 0;
46
+ const renumber = () => {
47
+ const count = Math.max(1, textarea.value.split('\n').length);
48
+ if (count !== lastCount) {
49
+ gutter.textContent = lineNumbers(count);
50
+ lastCount = count;
51
+ }
52
+ };
53
+ const syncScroll = () => {
54
+ gutter.scrollTop = textarea.scrollTop;
55
+ };
56
+ const onInput = () => {
57
+ renumber();
58
+ syncScroll();
59
+ };
60
+
61
+ textarea.addEventListener('input', onInput);
62
+ textarea.addEventListener('scroll', syncScroll);
63
+ renumber();
64
+ syncScroll();
65
+
66
+ return () => {
67
+ textarea.removeEventListener('input', onInput);
68
+ textarea.removeEventListener('scroll', syncScroll);
69
+ gutter.remove();
70
+ if (prevWrap == null) textarea.removeAttribute('wrap');
71
+ else textarea.setAttribute('wrap', prevWrap);
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Install the editable-code behavior on the given root.
77
+ *
78
+ * Enhances every `.hc-code[data-editable]` once and re-scans subtrees
79
+ * delivered by htmx (`htmx:load`). Repeated calls on the same root return the
80
+ * same uninstaller.
81
+ *
82
+ * @param {Document|Element} [root=document]
83
+ * The scope to scan. Defaults to the global document when available.
84
+ * @returns {() => void} an idempotent uninstaller that removes the synced
85
+ * gutters and listeners it added.
86
+ */
87
+ export function installCodeEditor(root = (typeof document !== 'undefined' ? document : null)) {
88
+ if (!root) return () => {};
89
+ if (root[INSTALL_KEY]) return root[INSTALL_KEY];
90
+
91
+ const enhanced = new WeakSet();
92
+ const detachers = [];
93
+
94
+ const scan = (scope) => {
95
+ if (!scope || !scope.querySelectorAll) return;
96
+ scope.querySelectorAll('.hc-code[data-editable]').forEach((el) => {
97
+ if (enhanced.has(el)) return;
98
+ const detach = enhance(el);
99
+ if (detach) {
100
+ enhanced.add(el);
101
+ detachers.push(detach);
102
+ }
103
+ });
104
+ };
105
+
106
+ scan(root);
107
+
108
+ const target = root.body || root;
109
+ const onLoad = (event) => scan(event && event.target);
110
+ target.addEventListener('htmx:load', onLoad);
111
+
112
+ const uninstall = () => {
113
+ if (root[INSTALL_KEY] !== uninstall) return;
114
+ target.removeEventListener('htmx:load', onLoad);
115
+ detachers.forEach((fn) => fn());
116
+ detachers.length = 0;
117
+ delete root[INSTALL_KEY];
118
+ };
119
+ root[INSTALL_KEY] = uninstall;
120
+ return uninstall;
121
+ }
@@ -0,0 +1,275 @@
1
+ /* hc-code — code surface (issues #253, #256, #255).
2
+ *
3
+ * A monospace surface styled from the kit's tokens. The read-only modes
4
+ * need no script (CSP `default-src 'self'` safe); the editable mode is a
5
+ * real <textarea> that `installCodeEditor()` upgrades with a synced
6
+ * line-number gutter. Syntax highlighting is out of scope — the lines are
7
+ * plain text (a server-tokenized or behavior-driven follow-up may add it
8
+ * additively later).
9
+ *
10
+ * 1. Plain block — apply to a <pre>:
11
+ *
12
+ * <pre class="hc-code"><code>SELECT 1
13
+ * FROM t</code></pre>
14
+ *
15
+ * Overflow is a horizontal scroll by default; `data-wrap="on"` soft-wraps
16
+ * long lines instead.
17
+ *
18
+ * 2. Line-numbered / decorable — apply to an <ol>, one <li class="hc-code__line">
19
+ * per line, with `data-gutter="line-numbers"`:
20
+ *
21
+ * <ol class="hc-code" data-gutter="line-numbers">
22
+ * <li class="hc-code__line">SELECT *</li>
23
+ * <li class="hc-code__line" data-state="covered"> FROM orders</li>
24
+ * <li class="hc-code__line" data-state="missed"> WHERE total &gt; 0</li>
25
+ * </ol>
26
+ *
27
+ * 3. Unified diff — apply `data-mode="diff"` to the <ol>; each line carries a
28
+ * `data-state` of added | removed | context and `data-old` / `data-new`
29
+ * line numbers (the server computes the hunks — the kit only styles them):
30
+ *
31
+ * <ol class="hc-code" data-mode="diff">
32
+ * <li class="hc-code__line" data-state="context" data-old="12" data-new="12"> SELECT id</li>
33
+ * <li class="hc-code__line" data-state="removed" data-old="13"> FROM users</li>
34
+ * <li class="hc-code__line" data-state="added" data-new="13"> FROM app_users</li>
35
+ * </ol>
36
+ *
37
+ * 4. Editable — a <div class="hc-code" data-editable> wrapping a real
38
+ * <textarea class="hc-code__input" name="…">. The value submits in forms
39
+ * and with htmx; without JS it is a plain monospace textarea.
40
+ * `installCodeEditor()` adds a synced line-number gutter when
41
+ * `data-gutter="line-numbers"` is set:
42
+ *
43
+ * <div class="hc-code" data-editable data-gutter="line-numbers">
44
+ * <textarea class="hc-code__input" name="content" spellcheck="false">SELECT 1</textarea>
45
+ * </div>
46
+ *
47
+ * Per-line `data-state` colours from semantic status tokens, so the same
48
+ * markup themes correctly in light and dark:
49
+ * added / covered → success removed / missed → error context → muted
50
+ *
51
+ * Legend: `.hc-code__swatch[data-state]` is a small colour chip the consumer
52
+ * composes into a legend next to a coverage block.
53
+ *
54
+ * Accessibility: a horizontally scrollable block must be keyboard-reachable,
55
+ * so make it a focusable, labelled region — `tabindex="0"` plus
56
+ * `role="region"` and an `aria-label`. Colour is never the only diff cue: the
57
+ * unified-diff gutter prints a `+` / `-` sign glyph alongside the tint.
58
+ */
59
+ @layer hc.components {
60
+ .hc-code {
61
+ margin: 0;
62
+ border: 1px solid var(--hc-code-border);
63
+ border-radius: var(--hc-code-radius);
64
+ background: var(--hc-code-bg);
65
+ color: var(--hc-code-fg);
66
+ font-family: var(--hc-code-font-family);
67
+ font-size: var(--hc-code-font-size);
68
+ line-height: var(--hc-code-line-height);
69
+ overflow-x: auto;
70
+ tab-size: 2;
71
+ }
72
+
73
+ /* Plain block on a <pre>. */
74
+ .hc-code:where(pre) {
75
+ padding: var(--hc-code-padding-block) var(--hc-code-padding-inline);
76
+ white-space: pre;
77
+ }
78
+
79
+ .hc-code:where(pre)[data-wrap="on"] {
80
+ white-space: pre-wrap;
81
+ overflow-wrap: anywhere;
82
+ }
83
+
84
+ /* Line-based modes on an <ol>: reset the list, keep the inline padding on
85
+ * the lines so a line's background tint spans the full block width. */
86
+ .hc-code:where(ol) {
87
+ padding-block: var(--hc-code-padding-block);
88
+ padding-inline: 0;
89
+ list-style: none;
90
+ counter-reset: hc-code-line;
91
+ }
92
+
93
+ .hc-code__line {
94
+ padding-inline: var(--hc-code-padding-inline);
95
+ white-space: pre;
96
+ }
97
+
98
+ .hc-code[data-wrap="on"] .hc-code__line {
99
+ white-space: pre-wrap;
100
+ overflow-wrap: anywhere;
101
+ }
102
+
103
+ /* --- Line-number gutter ------------------------------------------------ */
104
+
105
+ .hc-code[data-gutter="line-numbers"] .hc-code__line {
106
+ position: relative;
107
+ counter-increment: hc-code-line;
108
+ padding-inline-start: var(--hc-code-gutter-width);
109
+ }
110
+
111
+ .hc-code[data-gutter="line-numbers"] .hc-code__line::before {
112
+ content: counter(hc-code-line);
113
+ position: absolute;
114
+ inset-inline-start: 0;
115
+ inline-size: calc(var(--hc-code-gutter-width) - var(--hc-code-gutter-gap));
116
+ color: var(--hc-code-gutter-fg);
117
+ text-align: end;
118
+ user-select: none;
119
+ }
120
+
121
+ /* --- Unified diff ------------------------------------------------------ */
122
+
123
+ .hc-code[data-mode="diff"] .hc-code__line {
124
+ position: relative;
125
+
126
+ /* Reserve two right-aligned number columns; the sign + code follow. */
127
+ padding-inline-start: calc(var(--hc-code-num-width) * 2 + var(--hc-code-gutter-gap));
128
+ list-style-position: inside;
129
+ }
130
+
131
+ .hc-code[data-mode="diff"] .hc-code__line::before {
132
+ content: attr(data-old);
133
+ position: absolute;
134
+ inset-inline-start: 0;
135
+ inline-size: var(--hc-code-num-width);
136
+ color: var(--hc-code-gutter-fg);
137
+ text-align: end;
138
+ user-select: none;
139
+ }
140
+
141
+ .hc-code[data-mode="diff"] .hc-code__line::after {
142
+ content: attr(data-new);
143
+ position: absolute;
144
+ inset-inline-start: var(--hc-code-num-width);
145
+ inline-size: var(--hc-code-num-width);
146
+ color: var(--hc-code-gutter-fg);
147
+ text-align: end;
148
+ user-select: none;
149
+ }
150
+
151
+ /* The +/- sign rides the list marker, so it is never selected when the
152
+ * code text is copied. A neutral two-space marker keeps context lines and
153
+ * the code column aligned. */
154
+ .hc-code[data-mode="diff"] .hc-code__line::marker {
155
+ content: " ";
156
+ color: var(--hc-code-gutter-fg);
157
+ }
158
+
159
+ .hc-code[data-mode="diff"] .hc-code__line[data-state="added"]::marker {
160
+ content: "+ ";
161
+ color: var(--hc-code-added-marker);
162
+ }
163
+
164
+ .hc-code[data-mode="diff"] .hc-code__line[data-state="removed"]::marker {
165
+ content: "- ";
166
+ color: var(--hc-code-removed-marker);
167
+ }
168
+
169
+ /* --- Per-line state (shared by gutter + diff modes) -------------------- */
170
+
171
+ .hc-code__line[data-state="added"],
172
+ .hc-code__line[data-state="covered"] {
173
+ background: var(--hc-code-added-bg);
174
+ box-shadow: inset 0.1875rem 0 0 var(--hc-code-added-marker);
175
+ }
176
+
177
+ .hc-code__line[data-state="removed"],
178
+ .hc-code__line[data-state="missed"] {
179
+ background: var(--hc-code-removed-bg);
180
+ box-shadow: inset 0.1875rem 0 0 var(--hc-code-removed-marker);
181
+ }
182
+
183
+ .hc-code__line[data-state="context"] {
184
+ color: var(--hc-code-context-fg);
185
+ }
186
+
187
+ /* --- Legend swatch ----------------------------------------------------- */
188
+
189
+ .hc-code__swatch {
190
+ display: inline-block;
191
+ inline-size: 0.75em;
192
+ block-size: 0.75em;
193
+ border: 1px solid var(--hc-code-border);
194
+ border-radius: 2px;
195
+ vertical-align: middle;
196
+ }
197
+
198
+ .hc-code__swatch[data-state="added"],
199
+ .hc-code__swatch[data-state="covered"] {
200
+ background: var(--hc-code-added-bg);
201
+ border-color: var(--hc-code-added-marker);
202
+ }
203
+
204
+ .hc-code__swatch[data-state="removed"],
205
+ .hc-code__swatch[data-state="missed"] {
206
+ background: var(--hc-code-removed-bg);
207
+ border-color: var(--hc-code-removed-marker);
208
+ }
209
+
210
+ /* --- Editable field (#255) --------------------------------------------- */
211
+
212
+ /* A real <textarea> on the same surface. The textarea drives the height;
213
+ * installCodeEditor() overlays the line-number gutter as an absolutely
214
+ * positioned column so it tracks the textarea's visible box (not its own
215
+ * content height) and scrolls in sync. Without JS it is just the textarea,
216
+ * full width. */
217
+ .hc-code[data-editable] {
218
+ position: relative;
219
+ display: block;
220
+ padding: 0;
221
+ overflow: hidden;
222
+ }
223
+
224
+ .hc-code[data-editable]:focus-within {
225
+ border-color: var(--hc-code-focus-border);
226
+ box-shadow: inset 0 0 0 1px var(--hc-code-focus-border);
227
+ }
228
+
229
+ /* The synced line-number gutter (created by the behavior, decorative).
230
+ * inset-block:0 ties its height to the textarea's visible box; overflow is
231
+ * hidden and the behavior matches its scrollTop to the textarea. */
232
+ .hc-code__gutter {
233
+ position: absolute;
234
+ inset-block: 0;
235
+ inset-inline-start: 0;
236
+ inline-size: var(--hc-code-gutter-width);
237
+ box-sizing: border-box;
238
+ padding-block: var(--hc-code-padding-block);
239
+ padding-inline-end: var(--hc-code-gutter-gap);
240
+ overflow: hidden;
241
+ color: var(--hc-code-gutter-fg);
242
+ text-align: end;
243
+ white-space: pre;
244
+ pointer-events: none;
245
+ user-select: none;
246
+ }
247
+
248
+ .hc-code__input {
249
+ display: block;
250
+ box-sizing: border-box;
251
+ inline-size: 100%;
252
+ min-block-size: var(--hc-code-input-min-height);
253
+ margin: 0;
254
+ padding-block: var(--hc-code-padding-block);
255
+ padding-inline: var(--hc-code-padding-inline);
256
+ border: 0;
257
+ background: transparent;
258
+ color: inherit;
259
+ font-family: inherit;
260
+ font-size: inherit;
261
+ line-height: inherit;
262
+ resize: vertical;
263
+ tab-size: inherit;
264
+ }
265
+
266
+ /* Make room for the gutter overlay. */
267
+ .hc-code[data-editable][data-gutter="line-numbers"] .hc-code__input {
268
+ padding-inline-start: var(--hc-code-gutter-width);
269
+ }
270
+
271
+ /* The container shows the :focus-within ring instead. */
272
+ .hc-code__input:focus {
273
+ outline: none;
274
+ }
275
+ }
@@ -0,0 +1,62 @@
1
+ /* hc-sparkline — a tiny inline trend chart (issue #254).
2
+ *
3
+ * A word-sized line (optionally filled) for showing a metric's recent
4
+ * direction next to the number, e.g. a coverage or pass-rate trend. It is
5
+ * CSP-safe (`default-src 'self'`): there is no charting dependency and no
6
+ * inline script — `installSparkline()` draws the SVG from `data-values` with
7
+ * the DOM API, and a server may emit the same SVG directly.
8
+ *
9
+ * <span class="hc-sparkline" data-values="0.7,0.74,0.8,0.78,0.82"
10
+ * aria-label="SQL line coverage trend"></span>
11
+ *
12
+ * The stroke and fill use `currentColor`, so a single `color` declaration
13
+ * themes the whole mark; `data-variant` swaps it for a success / warning /
14
+ * error trend colour. Size comes from `--hc-sparkline-width` / `-height`
15
+ * (or set the host's inline-size / block-size directly).
16
+ */
17
+ @layer hc.components {
18
+ .hc-sparkline {
19
+ display: inline-block;
20
+ inline-size: var(--hc-sparkline-width);
21
+ block-size: var(--hc-sparkline-height);
22
+ color: var(--hc-sparkline-color);
23
+ vertical-align: middle;
24
+ }
25
+
26
+ .hc-sparkline__svg {
27
+ display: block;
28
+ inline-size: 100%;
29
+ block-size: 100%;
30
+ overflow: visible;
31
+ }
32
+
33
+ .hc-sparkline__line {
34
+ fill: none;
35
+ stroke: currentColor;
36
+ stroke-width: var(--hc-sparkline-stroke-width);
37
+ stroke-linecap: round;
38
+ stroke-linejoin: round;
39
+
40
+ /* Keep the stroke crisp regardless of how the viewBox stretches to the
41
+ * host's aspect ratio (preserveAspectRatio="none"). */
42
+ vector-effect: non-scaling-stroke;
43
+ }
44
+
45
+ .hc-sparkline__area {
46
+ fill: currentColor;
47
+ opacity: var(--hc-sparkline-area-opacity);
48
+ stroke: none;
49
+ }
50
+
51
+ .hc-sparkline[data-variant="success"] {
52
+ color: var(--hc-sparkline-success);
53
+ }
54
+
55
+ .hc-sparkline[data-variant="warning"] {
56
+ color: var(--hc-sparkline-warning);
57
+ }
58
+
59
+ .hc-sparkline[data-variant="error"] {
60
+ color: var(--hc-sparkline-error);
61
+ }
62
+ }
@@ -30,5 +30,7 @@ import { installValidation } from './validation.js';
30
30
  import { installThemeToggle } from './theme-toggle.js';
31
31
  import { installFieldErrors } from './field-errors.js';
32
32
  import { installCsrfHeader } from './csrf-header.js';
33
- export { installConfirm, installToast, installCloseDialog, installClosePopover, installRemoteDialog, installTabs, installMenu, installMenubar, installNavmenu, installTooltip, installPopover, installSlider, installCombobox, installMulticombobox, installDrawer, installHovercard, installToggleGroup, installCarousel, installToolbar, installAvatar, installPasswordToggle, installContextMenu, installCommand, installCalendar, installInputOtp, installSplitter, installShell, installDatagrid, installValidation, installThemeToggle, installFieldErrors, installCsrfHeader };
33
+ import { installSparkline } from './sparkline.js';
34
+ import { installCodeEditor } from './code-editor.js';
35
+ export { installConfirm, installToast, installCloseDialog, installClosePopover, installRemoteDialog, installTabs, installMenu, installMenubar, installNavmenu, installTooltip, installPopover, installSlider, installCombobox, installMulticombobox, installDrawer, installHovercard, installToggleGroup, installCarousel, installToolbar, installAvatar, installPasswordToggle, installContextMenu, installCommand, installCalendar, installInputOtp, installSplitter, installShell, installDatagrid, installValidation, installThemeToggle, installFieldErrors, installCsrfHeader, installSparkline, installCodeEditor };
34
36
  export { setMessages, resetMessages, getMessages, hasMessage, DEFAULT_MESSAGES } from "./i18n.js";
@@ -39,6 +39,8 @@ import { installValidation } from './validation.js';
39
39
  import { installThemeToggle } from './theme-toggle.js';
40
40
  import { installFieldErrors } from './field-errors.js';
41
41
  import { installCsrfHeader } from './csrf-header.js';
42
+ import { installSparkline } from './sparkline.js';
43
+ import { installCodeEditor } from './code-editor.js';
42
44
 
43
45
  function init() {
44
46
  installConfirm();
@@ -73,6 +75,8 @@ function init() {
73
75
  installThemeToggle();
74
76
  installFieldErrors();
75
77
  installCsrfHeader();
78
+ installSparkline();
79
+ installCodeEditor();
76
80
  }
77
81
 
78
82
  if (typeof document !== 'undefined') {
@@ -116,6 +120,8 @@ export {
116
120
  installThemeToggle,
117
121
  installFieldErrors,
118
122
  installCsrfHeader,
123
+ installSparkline,
124
+ installCodeEditor,
119
125
  };
120
126
 
121
127
  // i18n — set the locale before this module's auto-init runs (e.g. inline