@hypermedia-components/core 0.1.0 → 0.1.2

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,25 @@
1
+ /**
2
+ * Install the csrf-header behavior: attach the page's CSRF token
3
+ * (`<meta name="csrf-token" content="…">`, header name from the meta's
4
+ * `data-header` attribute, default `X-CSRF-Token`) to every htmx
5
+ * request via `htmx:configRequest`.
6
+ *
7
+ * The meta tag is read at request time, so a rotated token is picked
8
+ * up automatically. A header already set on the request (e.g. via
9
+ * `data-hx-headers`) is left untouched. Without the meta tag the
10
+ * behavior is inert.
11
+ *
12
+ * @param {Document} [root]
13
+ * The root to listen on. Defaults to the global document when
14
+ * available.
15
+ * @returns {() => void} an idempotent uninstaller.
16
+ *
17
+ * @example
18
+ * // <head>
19
+ * // <meta name="csrf-token" content="3x4mpl3…">
20
+ * // </head>
21
+ *
22
+ * import { installCsrfHeader } from '@hypermedia-components/core';
23
+ * installCsrfHeader();
24
+ */
25
+ export function installCsrfHeader(root?: Document): () => void;
@@ -0,0 +1,83 @@
1
+ // csrf-header behavior — the blessed CSRF token delivery convention for
2
+ // htmx requests (#246).
3
+ //
4
+ // Contract:
5
+ // - The server's layout renders the token into the page head:
6
+ //
7
+ // <meta name="csrf-token" content="…">
8
+ //
9
+ // The header name defaults to `X-CSRF-Token` and is configurable on
10
+ // the carrier for stacks that expect a different one:
11
+ //
12
+ // <meta name="csrf-token" content="…" data-header="X-CSRFToken">
13
+ //
14
+ // - On every `htmx:configRequest` the behavior reads the meta tag —
15
+ // at request time, so server-side token rotation needs no
16
+ // re-install — and adds the header to the outgoing request.
17
+ // - A header already present in `event.detail.headers` is never
18
+ // overwritten: a per-request `data-hx-headers` (or an earlier
19
+ // listener) wins over the page-level convention.
20
+ // - No meta tag, or an empty `content` → strict no-op.
21
+ //
22
+ // The behavior never makes a request — htmx owns the network. Plain
23
+ // `<form method="post">` submissions never fire `htmx:configRequest`;
24
+ // no-JS degradation needs the framework's hidden-field mechanism.
25
+ //
26
+ // installCsrfHeader() returns an `uninstall` function. Idempotent.
27
+
28
+ const INSTALL_KEY = '__hcCsrfHeaderUninstall';
29
+
30
+ const DEFAULT_HEADER = 'X-CSRF-Token';
31
+
32
+ /**
33
+ * Install the csrf-header behavior: attach the page's CSRF token
34
+ * (`<meta name="csrf-token" content="…">`, header name from the meta's
35
+ * `data-header` attribute, default `X-CSRF-Token`) to every htmx
36
+ * request via `htmx:configRequest`.
37
+ *
38
+ * The meta tag is read at request time, so a rotated token is picked
39
+ * up automatically. A header already set on the request (e.g. via
40
+ * `data-hx-headers`) is left untouched. Without the meta tag the
41
+ * behavior is inert.
42
+ *
43
+ * @param {Document} [root]
44
+ * The root to listen on. Defaults to the global document when
45
+ * available.
46
+ * @returns {() => void} an idempotent uninstaller.
47
+ *
48
+ * @example
49
+ * // <head>
50
+ * // <meta name="csrf-token" content="3x4mpl3…">
51
+ * // </head>
52
+ *
53
+ * import { installCsrfHeader } from '@hypermedia-components/core';
54
+ * installCsrfHeader();
55
+ */
56
+ export function installCsrfHeader(root = (typeof document !== 'undefined' ? document : null)) {
57
+ if (!root) return () => {};
58
+ if (root[INSTALL_KEY]) return root[INSTALL_KEY];
59
+
60
+ function onConfigRequest(event) {
61
+ const headers = event?.detail?.headers;
62
+ if (!headers || typeof headers !== 'object') return;
63
+
64
+ const doc = root.nodeType === 9 ? root : root.ownerDocument;
65
+ const meta = doc.querySelector('meta[name="csrf-token"]');
66
+ const token = meta?.getAttribute('content');
67
+ if (!token) return;
68
+
69
+ const header = meta.getAttribute('data-header') || DEFAULT_HEADER;
70
+ if (header in headers) return; // an explicit per-request header wins
71
+ headers[header] = token;
72
+ }
73
+
74
+ root.addEventListener('htmx:configRequest', onConfigRequest);
75
+
76
+ const uninstall = () => {
77
+ if (root[INSTALL_KEY] !== uninstall) return;
78
+ root.removeEventListener('htmx:configRequest', onConfigRequest);
79
+ delete root[INSTALL_KEY];
80
+ };
81
+ root[INSTALL_KEY] = uninstall;
82
+ return uninstall;
83
+ }
package/dist/datagrid.js CHANGED
@@ -80,6 +80,32 @@ function rowCells(row) {
80
80
  );
81
81
  }
82
82
 
83
+ // The navigation matrix as a VISUAL grid: a cell with rowspan/colspan is
84
+ // entered into every (row, column) slot it covers, so arrow keys move by
85
+ // visual position and multi-row records stay column-aligned (the lead
86
+ // rowspan cell is reachable from every sub-row it spans).
87
+ function buildMatrix(grid) {
88
+ const rows = bodyRows(grid);
89
+ const out = rows.map(() => []);
90
+ rows.forEach((row, r) => {
91
+ let c = 0;
92
+ for (const cell of rowCells(row)) {
93
+ while (out[r][c] !== undefined) c += 1; // slot taken by a rowspan above
94
+ const cs = cell.colSpan || 1;
95
+ // rowspan="0" = "to the end of the row group" (HTML spec); either way
96
+ // a span never crosses into the next record's rows.
97
+ const rs = cell.rowSpan === 0 ? rows.length - r : cell.rowSpan || 1;
98
+ for (let dr = 0; dr < rs; dr += 1) {
99
+ const target = rows[r + dr];
100
+ if (!target || target.parentNode !== row.parentNode) break;
101
+ for (let dc = 0; dc < cs; dc += 1) out[r + dr][c + dc] = cell;
102
+ }
103
+ c += cs;
104
+ }
105
+ });
106
+ return out;
107
+ }
108
+
83
109
  /** Measure header heights + frozen widths → sticky offset variables. */
84
110
  function measure(grid) {
85
111
  const headTrs = ownedBy(grid, '.hc-datagrid__head > tr');
@@ -149,7 +175,8 @@ function attach(grid, detachers) {
149
175
  for (const h of ownedBy(grid, '.hc-datagrid__headcell')) {
150
176
  if (!h.getAttribute('role')) h.setAttribute('role', 'columnheader');
151
177
  }
152
- matrix.flat().forEach((cell) => {
178
+ // A spanning cell occupies several matrix slots — visit each cell once.
179
+ new Set(matrix.flat()).forEach((cell) => {
153
180
  cell.setAttribute('role', cell.tagName === 'TH' ? 'rowheader' : 'gridcell');
154
181
  cell.tabIndex = -1;
155
182
  // Widgets in cells are not separate tab stops — the grid manages focus.
@@ -160,15 +187,16 @@ function attach(grid, detachers) {
160
187
  }
161
188
 
162
189
  function rebuild() {
163
- matrix = bodyRows(grid).map(rowCells);
190
+ matrix = buildMatrix(grid);
164
191
  applyRoles();
165
192
  applyResizedWidths(); // re-apply column widths to swapped-in rows
166
- const cur = matrix[active.r]?.[active.c] ?? matrix[0]?.[0];
167
- if (cur) {
168
- cur.tabIndex = 0;
169
- const pos = locate(cur);
193
+ let cur = matrix[active.r]?.[active.c];
194
+ if (!cur) {
195
+ cur = matrix[0]?.[0];
196
+ const pos = cur && locate(cur);
170
197
  if (pos) active = pos;
171
198
  }
199
+ if (cur) cur.tabIndex = 0;
172
200
  }
173
201
 
174
202
  // ---- Column resize ----
@@ -331,6 +359,23 @@ function attach(grid, detachers) {
331
359
  cell.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
332
360
  }
333
361
 
362
+ // Arrow movement: walk from the active slot in direction (dr, dc),
363
+ // skipping further slots of the same spanning cell so a rowspan/colspan
364
+ // cell counts as a single stop. The active slot (not the cell's top-left)
365
+ // is the walk origin, so the entry row/column is kept while crossing a
366
+ // span — ↓ then ↑ round-trips.
367
+ function step(dr, dc) {
368
+ const { r, c } = active;
369
+ const cur = matrix[r]?.[c];
370
+ let nr = r + dr;
371
+ let nc = c + dc;
372
+ while (cur && matrix[nr]?.[nc] === cur) {
373
+ nr += dr;
374
+ nc += dc;
375
+ }
376
+ setActive(nr, nc);
377
+ }
378
+
334
379
  function toggleRow(r) {
335
380
  const row = bodyRows(grid)[r];
336
381
  if (!row) return;
@@ -386,10 +431,10 @@ function attach(grid, detachers) {
386
431
  else if (key === 'ArrowLeft') key = 'ArrowRight';
387
432
  }
388
433
  switch (key) {
389
- case 'ArrowDown': setActive(r + 1, c); break;
390
- case 'ArrowUp': setActive(r - 1, c); break;
391
- case 'ArrowRight': setActive(r, c + 1); break;
392
- case 'ArrowLeft': setActive(r, c - 1); break;
434
+ case 'ArrowDown': step(1, 0); break;
435
+ case 'ArrowUp': step(-1, 0); break;
436
+ case 'ArrowRight': step(0, 1); break;
437
+ case 'ArrowLeft': step(0, -1); break;
393
438
  case 'Home': setActive(event.ctrlKey ? 0 : r, 0); break;
394
439
  case 'End':
395
440
  if (event.ctrlKey) setActive(matrix.length - 1, Infinity);
@@ -419,9 +464,11 @@ function attach(grid, detachers) {
419
464
  if (!cell || !grid.contains(cell)) return;
420
465
  const pos = locate(cell);
421
466
  if (!pos) return;
422
- if (pos.r !== active.r || pos.c !== active.c) {
467
+ if (matrix[active.r]?.[active.c] !== cell) {
423
468
  setActive(pos.r, pos.c, false); // don't re-focus; focus is already here
424
469
  } else {
470
+ // Already the active cell — keep the active slot as-is so a spanning
471
+ // cell remembers which sub-row/column it was entered from.
425
472
  cell.setAttribute('data-active', '');
426
473
  }
427
474
  }
@@ -27,8 +27,11 @@
27
27
  // control is focused (opt out with `data-focus="none"` on the alert).
28
28
  //
29
29
  // Message resolution per item: `data-message-key` found in the i18n
30
- // catalog → `t(key, { field, code })`; otherwise the item's own text;
31
- // otherwise `t('fieldErrors.unknown')`. Localize once via `setMessages()`.
30
+ // catalog → `t(key, { field, code, ...data-message-params })`; otherwise
31
+ // the item's own text; otherwise `t('fieldErrors.unknown')`. Localize once
32
+ // via `setMessages()`. `data-message-params` is an optional JSON object of
33
+ // server-provided interpolation values (constraint declarations, validation
34
+ // row columns) for translations with placeholders beyond {field}/{code}.
32
35
  //
33
36
  // Server errors are stale the moment the user edits the field or
34
37
  // resubmits: cleared on first `input`/`change` per field, on `submit` /
@@ -65,16 +68,21 @@ function scopeOf(alert, root) {
65
68
  }
66
69
 
67
70
  // First control in the scope whose `name` matches. `form.elements`
68
- // handles radio/checkbox groups natively (RadioNodeList first member).
71
+ // handles radio/checkbox groups natively (RadioNodeList). Hidden inputs
72
+ // are skipped when the group has a visible member: the blessed boolean
73
+ // idiom pairs `<input type="hidden" value="false">` with the real
74
+ // checkbox under one name, and the ARIA wiring, focus, and edit-to-clear
75
+ // belong on the control the user can operate.
69
76
  function controlFor(scope, name) {
70
77
  let found;
71
78
  if (scope.elements && typeof scope.elements.namedItem === 'function') {
72
79
  found = scope.elements.namedItem(name);
73
80
  } else {
74
- found = scope.querySelector(`[name="${escapeName(name)}"]`);
81
+ found = scope.querySelectorAll(`[name="${escapeName(name)}"]`);
75
82
  }
76
83
  if (found && found.tagName == null && typeof found.length === 'number') {
77
- found = found[0] ?? null; // RadioNodeList
84
+ const members = Array.from(found); // RadioNodeList / NodeList
85
+ found = members.find((el) => el.type !== 'hidden') ?? members[0];
78
86
  }
79
87
  return found ?? null;
80
88
  }
@@ -85,6 +93,22 @@ function resolveMessage(item) {
85
93
  field: item.getAttribute('data-field') ?? '',
86
94
  code: item.getAttribute('data-code') ?? '',
87
95
  };
96
+ // Optional server-provided interpolation params (a JSON object), so a
97
+ // catalog translation may use placeholders beyond {field}/{code} — e.g.
98
+ // data-message-params='{"stock": 5}' for "在庫 {stock} を超えています。".
99
+ // Item params win over the implicit field/code; malformed or non-object
100
+ // JSON degrades to the attribute being ignored.
101
+ const raw = item.getAttribute('data-message-params');
102
+ if (raw) {
103
+ try {
104
+ const extra = JSON.parse(raw);
105
+ if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
106
+ Object.assign(params, extra);
107
+ }
108
+ } catch {
109
+ /* malformed JSON — keep the default params */
110
+ }
111
+ }
88
112
  if (key && hasMessage(key)) return t(key, params);
89
113
  const text = item.textContent.trim();
90
114
  if (text) return text;
@@ -51,7 +51,7 @@
51
51
  min-inline-size: var(--hc-combobox-listbox-min-width);
52
52
  max-block-size: var(--hc-combobox-listbox-max-height);
53
53
  overflow-y: auto;
54
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
54
+ box-shadow: var(--hc-shadow-lg);
55
55
  list-style: none;
56
56
  }
57
57
 
@@ -36,7 +36,7 @@
36
36
  background: var(--hc-command-bg);
37
37
  color: var(--hc-command-fg);
38
38
  overflow: hidden;
39
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
39
+ box-shadow: var(--hc-shadow-lg);
40
40
  }
41
41
 
42
42
  .hc-command__input {
@@ -38,7 +38,7 @@
38
38
  /* Contextual shadow cast by a frozen column's trailing edge — its
39
39
  direction flips per edge (see the frozen-edge rules below), so it
40
40
  stays a CSS-local var, not a theme token. */
41
- --hc-datagrid-freeze-shadow: 2px 0 4px -2px rgb(0, 0, 0, 0.25);
41
+ --hc-datagrid-freeze-shadow: 2px 0 4px -2px var(--hc-shadow-edge);
42
42
 
43
43
  position: relative;
44
44
  border: 1px solid var(--hc-datagrid-border);
@@ -173,7 +173,7 @@
173
173
 
174
174
  /* RTL: the freeze line falls on the other (inline-end) side. */
175
175
  .hc-datagrid:dir(rtl) {
176
- --hc-datagrid-freeze-shadow: -2px 0 4px -2px rgb(0, 0, 0, 0.25);
176
+ --hc-datagrid-freeze-shadow: -2px 0 4px -2px var(--hc-shadow-edge);
177
177
  }
178
178
 
179
179
  /* ---- States ----
@@ -355,7 +355,7 @@
355
355
  line-height: 1.4;
356
356
  white-space: normal;
357
357
  overflow-wrap: anywhere;
358
- box-shadow: 0 4px 12px rgb(0, 0, 0, 0.25);
358
+ box-shadow: var(--hc-shadow-lg);
359
359
  pointer-events: none;
360
360
  }
361
361
 
@@ -14,7 +14,7 @@
14
14
  color: var(--hc-dialog-fg);
15
15
  max-inline-size: var(--hc-dialog-max-width);
16
16
  inline-size: calc(100% - 2 * var(--hc-space-4));
17
- box-shadow: 0 10px 30px rgb(0, 0, 0, 0.15);
17
+ box-shadow: var(--hc-shadow-overlay);
18
18
  }
19
19
 
20
20
  .hc-dialog::backdrop {
@@ -30,7 +30,7 @@
30
30
  background: var(--hc-drawer-bg);
31
31
  color: var(--hc-drawer-fg);
32
32
  overflow: hidden;
33
- box-shadow: 0 0 30px rgb(0, 0, 0, 0.2);
33
+ box-shadow: var(--hc-shadow-overlay);
34
34
 
35
35
  /* Default side = right. Specific sides override below. */
36
36
  inset-block: 0;
@@ -40,7 +40,7 @@
40
40
  color: var(--hc-hovercard-fg);
41
41
  inline-size: max-content;
42
42
  max-inline-size: var(--hc-hovercard-max-width);
43
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.14);
43
+ box-shadow: var(--hc-shadow-lg);
44
44
 
45
45
  /* Shared directional placement (hc-anchored.css). */
46
46
  --hc-anchored-offset: var(--hc-hovercard-offset);
package/dist/hc-menu.css CHANGED
@@ -34,7 +34,7 @@
34
34
  color: var(--hc-menu-fg);
35
35
  min-inline-size: var(--hc-menu-min-width);
36
36
  max-inline-size: var(--hc-menu-max-width);
37
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
37
+ box-shadow: var(--hc-shadow-lg);
38
38
  }
39
39
 
40
40
  /* Anchor Positioning path. installMenu injects the matching
@@ -117,7 +117,7 @@
117
117
  min-inline-size: var(--hc-multicombobox-listbox-min-width);
118
118
  max-block-size: var(--hc-multicombobox-listbox-max-height);
119
119
  overflow-y: auto;
120
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
120
+ box-shadow: var(--hc-shadow-lg);
121
121
  list-style: none;
122
122
  }
123
123
 
@@ -77,7 +77,7 @@
77
77
  background: var(--hc-navmenu-panel-bg);
78
78
  color: var(--hc-navmenu-panel-fg);
79
79
  min-inline-size: var(--hc-navmenu-panel-min-width);
80
- box-shadow: 0 8px 24px rgb(0, 0, 0, 0.12);
80
+ box-shadow: var(--hc-shadow-lg);
81
81
  }
82
82
 
83
83
  /* Links inside a panel stack as a readable list. */
@@ -14,7 +14,7 @@
14
14
  color: var(--hc-popover-fg);
15
15
  min-inline-size: var(--hc-popover-min-width);
16
16
  max-inline-size: var(--hc-popover-max-width);
17
- box-shadow: 0 6px 20px rgb(0, 0, 0, 0.12);
17
+ box-shadow: var(--hc-shadow-lg);
18
18
 
19
19
  /* Shared directional placement (hc-anchored.css). Anchoring is opt-in via
20
20
  * data-side + installPopover; a bare popover stays browser-positioned. */
@@ -46,7 +46,7 @@
46
46
  block-size: var(--hc-switch-thumb-size);
47
47
  background-color: var(--hc-switch-thumb-bg);
48
48
  border-radius: 50%;
49
- box-shadow: 0 1px 2px rgb(0, 0, 0, 0.15);
49
+ box-shadow: var(--hc-shadow-sm);
50
50
  translate: 0 -50%;
51
51
  transition: translate 120ms ease;
52
52
  }
package/dist/hc-tabs.css CHANGED
@@ -226,12 +226,12 @@
226
226
 
227
227
  .hc-tabs__scroll[data-dir="start"] {
228
228
  inset-inline-start: 0;
229
- box-shadow: 4px 0 6px -4px rgb(0, 0, 0, 0.2);
229
+ box-shadow: 4px 0 6px -4px var(--hc-shadow-edge);
230
230
  }
231
231
 
232
232
  .hc-tabs__scroll[data-dir="end"] {
233
233
  inset-inline-end: 0;
234
- box-shadow: -4px 0 6px -4px rgb(0, 0, 0, 0.2);
234
+ box-shadow: -4px 0 6px -4px var(--hc-shadow-edge);
235
235
  }
236
236
 
237
237
  .hc-tabs__scroll::before {
package/dist/hc-toast.css CHANGED
@@ -66,7 +66,7 @@
66
66
  border: 1px solid var(--hc-toast-info-border);
67
67
  background: var(--hc-toast-info-bg);
68
68
  color: var(--hc-toast-info-fg);
69
- box-shadow: 0 4px 12px rgb(0, 0, 0, 0.08);
69
+ box-shadow: var(--hc-shadow-md);
70
70
 
71
71
  /* Horizontal drag is the swipe-to-dismiss gesture; the behavior animates
72
72
  * the snap-back / fly-out via these transitions. */
@@ -29,5 +29,6 @@ import { installDatagrid } from './datagrid.js';
29
29
  import { installValidation } from './validation.js';
30
30
  import { installThemeToggle } from './theme-toggle.js';
31
31
  import { installFieldErrors } from './field-errors.js';
32
- 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 };
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
34
  export { setMessages, resetMessages, getMessages, hasMessage, DEFAULT_MESSAGES } from "./i18n.js";
@@ -38,6 +38,7 @@ import { installDatagrid } from './datagrid.js';
38
38
  import { installValidation } from './validation.js';
39
39
  import { installThemeToggle } from './theme-toggle.js';
40
40
  import { installFieldErrors } from './field-errors.js';
41
+ import { installCsrfHeader } from './csrf-header.js';
41
42
 
42
43
  function init() {
43
44
  installConfirm();
@@ -71,6 +72,7 @@ function init() {
71
72
  installValidation();
72
73
  installThemeToggle();
73
74
  installFieldErrors();
75
+ installCsrfHeader();
74
76
  }
75
77
 
76
78
  if (typeof document !== 'undefined') {
@@ -113,6 +115,7 @@ export {
113
115
  installValidation,
114
116
  installThemeToggle,
115
117
  installFieldErrors,
118
+ installCsrfHeader,
116
119
  };
117
120
 
118
121
  // i18n — set the locale before this module's auto-init runs (e.g. inline