shelving 1.210.0 → 1.212.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.
@@ -17,7 +17,6 @@ export declare const MARKUP_RULES_INLINE: MarkupRules;
17
17
  * - Hard because you have to capture the entire list including `\n\n`, so there's no obvious place to end it.
18
18
  * - If there are breaks then any sub-lines need to be indented by two or more spaces otherwise it will break the list.
19
19
  * - Make reference lists support this loose format too.
20
- * @todo [ ] Default rules support tables using `|` pipe syntax.
21
20
  * @todo [ ] Default rules support todo lists using `- [x]` syntax.
22
21
  * @todo [ ] Default rules support new reference syntax (combines reference lists/sidenotes/footnotes/reference and produces <dl> syntax).
23
22
  * - All of these can be the same because reference links and Extended Markdown footnotes are basically the same.
@@ -44,4 +43,5 @@ export * from "./link.js";
44
43
  export * from "./ordered.js";
45
44
  export * from "./paragraph.js";
46
45
  export * from "./separator.js";
46
+ export * from "./table.js";
47
47
  export * from "./unordered.js";
@@ -8,6 +8,7 @@ import { AUTOLINK_RULE, LINK_RULE } from "./link.js";
8
8
  import { ORDERED_RULE } from "./ordered.js";
9
9
  import { PARAGRAPH_RULE } from "./paragraph.js";
10
10
  import { SEPARATOR_RULE } from "./separator.js";
11
+ import { TABLE_RULE } from "./table.js";
11
12
  import { UNORDERED_RULE } from "./unordered.js";
12
13
  /** Markup rules that work in a block context. */
13
14
  export const MARKUP_RULES_BLOCK = [
@@ -17,6 +18,7 @@ export const MARKUP_RULES_BLOCK = [
17
18
  UNORDERED_RULE,
18
19
  ORDERED_RULE,
19
20
  BLOCKQUOTE_RULE,
21
+ TABLE_RULE,
20
22
  PARAGRAPH_RULE,
21
23
  ];
22
24
  /** Markup rules that work in an inline context. */
@@ -34,7 +36,6 @@ export const MARKUP_RULES_INLINE = [CODE_RULE, LINK_RULE, AUTOLINK_RULE, INLINE_
34
36
  * - Hard because you have to capture the entire list including `\n\n`, so there's no obvious place to end it.
35
37
  * - If there are breaks then any sub-lines need to be indented by two or more spaces otherwise it will break the list.
36
38
  * - Make reference lists support this loose format too.
37
- * @todo [ ] Default rules support tables using `|` pipe syntax.
38
39
  * @todo [ ] Default rules support todo lists using `- [x]` syntax.
39
40
  * @todo [ ] Default rules support new reference syntax (combines reference lists/sidenotes/footnotes/reference and produces <dl> syntax).
40
41
  * - All of these can be the same because reference links and Extended Markdown footnotes are basically the same.
@@ -61,4 +62,5 @@ export * from "./link.js";
61
62
  export * from "./ordered.js";
62
63
  export * from "./paragraph.js";
63
64
  export * from "./separator.js";
65
+ export * from "./table.js";
64
66
  export * from "./unordered.js";
@@ -0,0 +1,13 @@
1
+ /** Regular expression matching a table block: a header row, a delimiter row, then any number of pipe rows. */
2
+ export declare const TABLE_REGEXP: import("../../index.js").NamedRegExp<{
3
+ table: string;
4
+ }>;
5
+ /**
6
+ * Table.
7
+ * - Markdown-style pipe table: a header row, a `|---|` delimiter row, then body rows.
8
+ * - Cells are pipe-separated; outer pipes are optional and whitespace around cells is trimmed.
9
+ * - Extra `|---|` delimiter rows split the table into sections: the first section becomes `<thead>`, the last becomes `<tfoot>` (only when there are three or more sections), and every section in between becomes its own `<tbody>`.
10
+ * - Column count and per-column alignment (`:--` left, `--:` right, `:-:` centered) come from the first delimiter row; ragged rows are padded or truncated to that count.
11
+ * - Cell content is rendered as inline markup; write `\|` for a literal pipe inside a cell.
12
+ */
13
+ export declare const TABLE_RULE: import("../util/rule.js").MarkupRule;
@@ -0,0 +1,92 @@
1
+ import { renderMarkup } from "../render.js";
2
+ import { REACT_ELEMENT_TYPE } from "../util/internal.js";
3
+ import { createBlockRegExp, LINE_SPACE_REGEXP } from "../util/regexp.js";
4
+ import { createMarkupRule } from "../util/rule.js";
5
+ // Constants.
6
+ const _SPACE = `${LINE_SPACE_REGEXP}*`; // Run of line whitespace (never crosses a newline).
7
+ const _CELL = `${_SPACE}:?-+:?${_SPACE}`; // Delimiter-row cell: one or more dashes with optional `:` alignment markers.
8
+ const _DELIMITER_SOURCE = `${_SPACE}\\|?(?:${_CELL}\\|)+(?:${_CELL})?${_SPACE}`; // Delimiter row: pipe-separated dash cells.
9
+ const _DELIMITER = new RegExp(`^${_DELIMITER_SOURCE}$`, "u"); // Tests whether a single line is a delimiter row.
10
+ const _ROW = "[^\\n]*\\|[^\\n]*"; // Any line containing at least one pipe.
11
+ const _SPLIT = /(?<!\\)\|/; // Splits a row into cells on unescaped pipes.
12
+ /** Regular expression matching a table block: a header row, a delimiter row, then any number of pipe rows. */
13
+ export const TABLE_REGEXP = createBlockRegExp(`(?<table>${_ROW}\\n${_DELIMITER_SOURCE}(?:\\n${_ROW})*)`);
14
+ /**
15
+ * Table.
16
+ * - Markdown-style pipe table: a header row, a `|---|` delimiter row, then body rows.
17
+ * - Cells are pipe-separated; outer pipes are optional and whitespace around cells is trimmed.
18
+ * - Extra `|---|` delimiter rows split the table into sections: the first section becomes `<thead>`, the last becomes `<tfoot>` (only when there are three or more sections), and every section in between becomes its own `<tbody>`.
19
+ * - Column count and per-column alignment (`:--` left, `--:` right, `:-:` centered) come from the first delimiter row; ragged rows are padded or truncated to that count.
20
+ * - Cell content is rendered as inline markup; write `\|` for a literal pipe inside a cell.
21
+ */
22
+ export const TABLE_RULE = createMarkupRule(TABLE_REGEXP, ({ groups: { table } }, options, key) => _renderTable(table, options, key), [
23
+ "block",
24
+ ]);
25
+ /** Render a matched table block into a `<table>` element. */
26
+ function _renderTable(table, options, key) {
27
+ const lines = table.split("\n");
28
+ // Column count and alignment come from the first delimiter row — always line 1, guaranteed by `TABLE_REGEXP`.
29
+ const aligns = _splitRow(lines[1] ?? "").map(_getAlign);
30
+ // Split lines into sections at delimiter rows. Line 0 is the header and is never treated as a delimiter.
31
+ const sections = [];
32
+ let section = [lines[0] ?? ""];
33
+ for (let i = 1; i < lines.length; i++) {
34
+ const line = lines[i] ?? "";
35
+ if (_DELIMITER.test(line)) {
36
+ sections.push(section);
37
+ section = [];
38
+ }
39
+ else {
40
+ section.push(line);
41
+ }
42
+ }
43
+ sections.push(section);
44
+ // First section is `<thead>`; the last is `<tfoot>` when there are 3+ sections; sections in between are each a `<tbody>`.
45
+ const last = sections.length - 1;
46
+ const children = sections.map((rows, s) => {
47
+ const type = s === 0 ? "thead" : s === last && last >= 2 ? "tfoot" : "tbody";
48
+ return {
49
+ $$typeof: REACT_ELEMENT_TYPE,
50
+ type,
51
+ key: `${type}-${s}`,
52
+ props: { children: Array.from(_renderRows(rows, s === 0 ? "th" : "td", aligns, options)) },
53
+ };
54
+ });
55
+ return { key, $$typeof: REACT_ELEMENT_TYPE, type: "table", props: { children } };
56
+ }
57
+ /** Render the rows of one section into `<tr>` elements of `<th>` or `<td>` cells. */
58
+ function* _renderRows(rows, cell, aligns, options) {
59
+ let r = 0;
60
+ for (const row of rows) {
61
+ const values = _splitRow(row);
62
+ const cells = aligns.map((align, c) => {
63
+ const children = renderMarkup(values[c] ?? "", options, "inline");
64
+ return {
65
+ $$typeof: REACT_ELEMENT_TYPE,
66
+ type: cell,
67
+ key: c.toString(),
68
+ props: align ? { align, children } : { children },
69
+ };
70
+ });
71
+ yield { $$typeof: REACT_ELEMENT_TYPE, type: "tr", key: (r++).toString(), props: { children: cells } };
72
+ }
73
+ }
74
+ /** Split a table row into trimmed cell strings, honouring `\|` escaped pipes. */
75
+ function _splitRow(row) {
76
+ let line = row.trim();
77
+ if (line.startsWith("|"))
78
+ line = line.slice(1);
79
+ if (line.endsWith("|"))
80
+ line = line.slice(0, -1);
81
+ return line.split(_SPLIT).map(cell => cell.trim().replaceAll("\\|", "|"));
82
+ }
83
+ /** Get the alignment of a delimiter-row cell, or `undefined` for the default (left). */
84
+ function _getAlign(cell) {
85
+ const start = cell.startsWith(":");
86
+ const end = cell.endsWith(":");
87
+ if (start && end)
88
+ return "center";
89
+ if (end)
90
+ return "right";
91
+ return undefined;
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.210.0",
3
+ "version": "1.212.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,7 +15,7 @@ html:has(.layout) {
15
15
  width: 100vw;
16
16
  width: 100dvw;
17
17
  overflow: hidden;
18
- overflow-wrap: anywhere;
18
+ overflow-wrap: break-word; /* Break overlong words only as a last resort; `anywhere` would also break mid-word to fit narrow table/flex columns. */
19
19
  overscroll-behavior: none;
20
20
  }
21
21
 
@@ -1,4 +1,4 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
3
3
  export interface SidebarLayoutProps {
4
4
  /** Content rendered in the fixed-width side column. */
@@ -11,7 +11,8 @@ export interface SidebarLayoutProps {
11
11
  /**
12
12
  * Layout with a fixed-width side column (typically navigation) next to a scrollable main content column.
13
13
  * - The sidebar is rendered as `<nav>` — it almost always contains the page's primary navigation.
14
- * - The sidebar collapses above the main content on narrow viewports.
14
+ * - On narrow viewports the sidebar slides off the left of the screen and is toggled with a "show menu" button.
15
+ * - The toggle is driven by a hidden checkbox and pure CSS, so it works even when the page ships no client-side JavaScript.
15
16
  * - Use the `--sidebar-layout-width` and `--sidebar-layout-bg` custom properties to override defaults.
16
17
  */
17
18
  export declare function SidebarLayout({ sidebar, children, right }: SidebarLayoutProps): ReactElement;
@@ -1,16 +1,21 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
3
+ import { useId } from "react";
2
4
  import { getClass } from "../util/css.js";
3
5
  import { LAYOUT_CSS } from "./Layout.js";
4
6
  import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
5
7
  /**
6
8
  * Layout with a fixed-width side column (typically navigation) next to a scrollable main content column.
7
9
  * - The sidebar is rendered as `<nav>` — it almost always contains the page's primary navigation.
8
- * - The sidebar collapses above the main content on narrow viewports.
10
+ * - On narrow viewports the sidebar slides off the left of the screen and is toggled with a "show menu" button.
11
+ * - The toggle is driven by a hidden checkbox and pure CSS, so it works even when the page ships no client-side JavaScript.
9
12
  * - Use the `--sidebar-layout-width` and `--sidebar-layout-bg` custom properties to override defaults.
10
13
  */
11
14
  export function SidebarLayout({ sidebar, children, right = false }) {
12
- const sidebarEl = (_jsx("nav", { className: SIDEBAR_LAYOUT_CSS.sidebar, children: sidebar }, "sidebar"));
13
- const contentEl = (_jsx("div", { className: getClass(LAYOUT_CSS.layout, SIDEBAR_LAYOUT_CSS.content), children: _jsx("div", { className: SIDEBAR_LAYOUT_CSS.contentInner, children: children }) }, "content"));
14
- return (_jsx("main", { className: getClass(SIDEBAR_LAYOUT_CSS.main, LAYOUT_CSS.layout), children: right ? [contentEl, sidebarEl] : [sidebarEl, contentEl] }));
15
+ // Ties the toggle checkbox to its `<label>` buttons `useId()` is stable during static (non-hydrated) rendering.
16
+ const id = useId();
17
+ const sidebarEl = (_jsxs("nav", { className: SIDEBAR_LAYOUT_CSS.sidebar, children: [_jsx("label", { htmlFor: id, title: "Close menu", className: SIDEBAR_LAYOUT_CSS.close, children: _jsx(XMarkIcon, {}) }), sidebar] }, "sidebar"));
18
+ const contentEl = (_jsxs("div", { className: getClass(LAYOUT_CSS.layout, SIDEBAR_LAYOUT_CSS.content), children: [_jsx("label", { htmlFor: id, title: "Show menu", className: SIDEBAR_LAYOUT_CSS.show, children: _jsx(Bars3Icon, {}) }), _jsx("div", { className: SIDEBAR_LAYOUT_CSS.contentInner, children: children })] }, "content"));
19
+ return (_jsxs("main", { className: getClass(SIDEBAR_LAYOUT_CSS.main, LAYOUT_CSS.layout), children: [_jsx("input", { type: "checkbox", id: id, className: SIDEBAR_LAYOUT_CSS.toggle, "aria-label": "Show or hide menu" }), right ? [contentEl, sidebarEl] : [sidebarEl, contentEl]] }));
15
20
  }
16
21
  export { SIDEBAR_LAYOUT_CSS };
@@ -5,6 +5,10 @@
5
5
  * here we override its block-level behaviour so the sidebar and content each scroll independently.
6
6
  * The inner `.content` element reuses the `.layout` class too so it picks up the standard
7
7
  * layout padding, safe-area insets, and `overflow: hidden auto` scroll behaviour.
8
+ *
9
+ * On narrow viewports the sidebar becomes an off-canvas drawer: it slides off the left edge of the
10
+ * screen and is toggled by the hidden `.toggle` checkbox via its `.show` / `.close` `<label>` buttons.
11
+ * Driving the drawer with a checkbox + CSS means it works even when the page ships no client-side JS.
8
12
  */
9
13
 
10
14
  .main {
@@ -34,14 +38,77 @@
34
38
  margin: 0 auto;
35
39
  }
36
40
 
37
- /* On narrow viewports collapse to a single column with the sidebar above. */
41
+ /* Toggle checkbox hidden entirely on wide viewports (see media query for the narrow-viewport state). */
42
+ .toggle {
43
+ display: none;
44
+ }
45
+
46
+ /* Menu toggle buttons — hidden by default, only shown on narrow viewports (see media query below). */
47
+ .show,
48
+ .close {
49
+ display: none;
50
+ width: fit-content;
51
+ margin: 0;
52
+ align-items: center;
53
+ justify-content: center;
54
+ padding: var(--space-small);
55
+ border-radius: var(--radius-xsmall);
56
+ color: var(--color-text);
57
+ cursor: pointer;
58
+
59
+ & svg {
60
+ width: var(--size-icon);
61
+ height: var(--size-icon);
62
+ }
63
+
64
+ &:hover {
65
+ background: var(--color-surface);
66
+ }
67
+ }
68
+
69
+ /* The close button sits at the top of the sidebar, aligned to its trailing edge. */
70
+ .close {
71
+ margin-inline-start: auto;
72
+ margin-bottom: var(--space-xsmall);
73
+ }
74
+
75
+ /* On narrow viewports the sidebar becomes an off-canvas drawer that slides in from the left. */
38
76
  @media (max-width: 48rem) {
39
77
  .main {
40
78
  grid-template-columns: 1fr;
41
79
  }
42
80
  .sidebar {
43
- border-right: none;
44
- border-bottom: 1px solid var(--color-border);
45
- max-height: 50vh;
81
+ position: fixed;
82
+ z-index: 100;
83
+ inset-block: 0;
84
+ left: 0;
85
+ width: var(--sidebar-layout-width, 17.5rem);
86
+ max-width: 85vw;
87
+ transform: translateX(-100%);
88
+ transition: transform var(--duration-normal) ease-in-out;
89
+ }
90
+ /* Checked checkbox → drawer slides on screen. */
91
+ .toggle:checked ~ .sidebar {
92
+ transform: translateX(0);
93
+ box-shadow: 0 0 1.5rem var(--color-overlay);
94
+ }
95
+ /* Keep the checkbox visually hidden but still focusable for keyboard users. */
96
+ .toggle {
97
+ display: block;
98
+ position: absolute;
99
+ width: 1px;
100
+ height: 1px;
101
+ overflow: hidden;
102
+ clip-path: inset(50%);
103
+ }
104
+ .show,
105
+ .close {
106
+ display: flex;
107
+ }
108
+ /* Forward the checkbox's keyboard focus ring to whichever toggle button is on screen. */
109
+ .toggle:focus-visible ~ .content .show,
110
+ .toggle:focus-visible ~ .sidebar .close {
111
+ outline: var(--stroke-focus) solid var(--color-focus);
112
+ outline-offset: 2px;
46
113
  }
47
114
  }
@@ -1,4 +1,5 @@
1
- import type { ReactElement, ReactNode } from "react";
1
+ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
2
+ import { type ReactElement, type ReactNode, useId } from "react";
2
3
  import { getClass } from "../util/css.js";
3
4
  import { LAYOUT_CSS } from "./Layout.js";
4
5
  import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
@@ -15,22 +16,35 @@ export interface SidebarLayoutProps {
15
16
  /**
16
17
  * Layout with a fixed-width side column (typically navigation) next to a scrollable main content column.
17
18
  * - The sidebar is rendered as `<nav>` — it almost always contains the page's primary navigation.
18
- * - The sidebar collapses above the main content on narrow viewports.
19
+ * - On narrow viewports the sidebar slides off the left of the screen and is toggled with a "show menu" button.
20
+ * - The toggle is driven by a hidden checkbox and pure CSS, so it works even when the page ships no client-side JavaScript.
19
21
  * - Use the `--sidebar-layout-width` and `--sidebar-layout-bg` custom properties to override defaults.
20
22
  */
21
23
  export function SidebarLayout({ sidebar, children, right = false }: SidebarLayoutProps): ReactElement {
24
+ // Ties the toggle checkbox to its `<label>` buttons — `useId()` is stable during static (non-hydrated) rendering.
25
+ const id = useId();
26
+
22
27
  const sidebarEl = (
23
28
  <nav key="sidebar" className={SIDEBAR_LAYOUT_CSS.sidebar}>
29
+ <label htmlFor={id} title="Close menu" className={SIDEBAR_LAYOUT_CSS.close}>
30
+ <XMarkIcon />
31
+ </label>
24
32
  {sidebar}
25
33
  </nav>
26
34
  );
27
35
  const contentEl = (
28
36
  <div key="content" className={getClass(LAYOUT_CSS.layout, SIDEBAR_LAYOUT_CSS.content)}>
37
+ <label htmlFor={id} title="Show menu" className={SIDEBAR_LAYOUT_CSS.show}>
38
+ <Bars3Icon />
39
+ </label>
29
40
  <div className={SIDEBAR_LAYOUT_CSS.contentInner}>{children}</div>
30
41
  </div>
31
42
  );
32
43
  return (
33
- <main className={getClass(SIDEBAR_LAYOUT_CSS.main, LAYOUT_CSS.layout)}>{right ? [contentEl, sidebarEl] : [sidebarEl, contentEl]}</main>
44
+ <main className={getClass(SIDEBAR_LAYOUT_CSS.main, LAYOUT_CSS.layout)}>
45
+ <input type="checkbox" id={id} className={SIDEBAR_LAYOUT_CSS.toggle} aria-label="Show or hide menu" />
46
+ {right ? [contentEl, sidebarEl] : [sidebarEl, contentEl]}
47
+ </main>
34
48
  );
35
49
  }
36
50