@charcuterie/ui 3.17.0 → 3.19.1
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/Combobox/Combobox.d.ts +14 -1
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +124 -56
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/Combobox/ComboboxOption.d.ts +4 -1
- package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
- package/dist/Combobox/ComboboxOption.js +8 -2
- package/dist/Combobox/ComboboxOption.js.map +1 -1
- package/dist/Listbox/Listbox.d.ts +13 -1
- package/dist/Listbox/Listbox.d.ts.map +1 -1
- package/dist/Listbox/Listbox.js +4 -2
- package/dist/Listbox/Listbox.js.map +1 -1
- package/dist/Listbox/ListboxOption.d.ts +4 -1
- package/dist/Listbox/ListboxOption.d.ts.map +1 -1
- package/dist/Listbox/ListboxOption.js +7 -2
- package/dist/Listbox/ListboxOption.js.map +1 -1
- package/dist/Menu/Menu.d.ts +15 -1
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +39 -5
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/Menu/MenuAction.d.ts +4 -1
- package/dist/Menu/MenuAction.d.ts.map +1 -1
- package/dist/Menu/MenuAction.js +22 -4
- package/dist/Menu/MenuAction.js.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts +25 -1
- package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
- package/dist/Overlay/useAnchoredOverlay.js +8 -4
- package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
- package/dist/Overlay/usePanelItemSize.d.ts +3 -0
- package/dist/Overlay/usePanelItemSize.d.ts.map +1 -0
- package/dist/Overlay/usePanelItemSize.js +59 -0
- package/dist/Overlay/usePanelItemSize.js.map +1 -0
- package/dist/controlStyles.d.ts +30 -0
- package/dist/controlStyles.d.ts.map +1 -1
- package/dist/controlStyles.js +34 -0
- package/dist/controlStyles.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +24 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownView.js +14 -2
- package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +7 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +28 -2
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +22 -2
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +26 -7
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
- package/dist/viewport.testHelpers.d.ts +18 -4
- package/dist/viewport.testHelpers.d.ts.map +1 -1
- package/dist/viewport.testHelpers.js +18 -6
- package/dist/viewport.testHelpers.js.map +1 -1
- package/package.json +2 -2
- package/src/Combobox/Combobox.mdx +26 -0
- package/src/Combobox/Combobox.stories.tsx +65 -0
- package/src/Combobox/Combobox.test.tsx +135 -0
- package/src/Combobox/Combobox.tsx +176 -83
- package/src/Combobox/ComboboxOption.tsx +11 -1
- package/src/Listbox/Listbox.mdx +24 -0
- package/src/Listbox/Listbox.stories.tsx +81 -5
- package/src/Listbox/Listbox.tsx +17 -0
- package/src/Listbox/ListboxOption.tsx +10 -1
- package/src/Menu/Menu.mdx +49 -0
- package/src/Menu/Menu.stories.tsx +113 -2
- package/src/Menu/Menu.test.tsx +129 -0
- package/src/Menu/Menu.tsx +62 -3
- package/src/Menu/MenuAction.tsx +29 -4
- package/src/Overlay/useAnchoredOverlay.ts +33 -5
- package/src/Overlay/usePanelItemSize.ts +71 -0
- package/src/controlStyles.ts +38 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +185 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +42 -1
- package/src/markdownEditorCodeMirror/MarkdownView.tsx +40 -1
- package/src/markdownEditorCodeMirror/livePreview.ts +40 -3
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +137 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +50 -6
- package/src/sourceRules.test.ts +9 -0
- package/src/viewport.testHelpers.ts +21 -6
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useMediaQuery } from "@charcuterie/logic"
|
|
2
|
+
import { matchMediaMatcher } from "@charcuterie/logic/browser"
|
|
3
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The requested row size, stepped down when the window is too short
|
|
7
|
+
* to spend the height.
|
|
8
|
+
*
|
|
9
|
+
* A fat option row is a bigger click target, which is the whole point
|
|
10
|
+
* of `itemSize` — but a nine-item menu at `lg` is 400px of panel, and
|
|
11
|
+
* on a half-height window that is the difference between a menu you
|
|
12
|
+
* read at a glance and a menu you scroll. So the size a panel asks
|
|
13
|
+
* for is a *ceiling*: below 40rem of viewport height it drops one
|
|
14
|
+
* step, and below 30rem it drops to the floor.
|
|
15
|
+
*
|
|
16
|
+
* ### Why a hook and not a media query in CSS
|
|
17
|
+
*
|
|
18
|
+
* Both were written. CSS is the library's usual answer — the density
|
|
19
|
+
* axis rewrites `--control-height-*` and nothing re-renders — but it
|
|
20
|
+
* cannot step *`text-sm` to `text-md`*: those are Tailwind bridges
|
|
21
|
+
* onto `--font-size-*` rather than variables a media query can
|
|
22
|
+
* reassign, and the alternative (`text-(length:--panel-item-font-size-lg)`
|
|
23
|
+
* everywhere, over nine new tokens) is the indirection
|
|
24
|
+
* `buildCss.ts` explicitly argues against. Stepping the whole
|
|
25
|
+
* `ControlSize` in JS keeps one literal class string per size, which
|
|
26
|
+
* is also what `tailwindCandidates.test.ts` can see.
|
|
27
|
+
*
|
|
28
|
+
* The re-render this costs happens on a window resize, not on a
|
|
29
|
+
* scheme or density flip — those stay repaints.
|
|
30
|
+
*
|
|
31
|
+
* ### `height`, not `width`
|
|
32
|
+
*
|
|
33
|
+
* Rows stack down the panel, so height is the axis that runs out.
|
|
34
|
+
* The Narrow View is a separate question and this is not it: a 390px
|
|
35
|
+
* phone held upright has plenty of height, and its menu stays fat.
|
|
36
|
+
*/
|
|
37
|
+
const SHORT_VIEWPORT_QUERY = "(height <= 40rem)"
|
|
38
|
+
|
|
39
|
+
const VERY_SHORT_VIEWPORT_QUERY = "(height <= 30rem)"
|
|
40
|
+
|
|
41
|
+
const STEP_DOWN: Record<ControlSize, ControlSize> = {
|
|
42
|
+
sm: "sm",
|
|
43
|
+
md: "sm",
|
|
44
|
+
lg: "md",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const usePanelItemSize = (
|
|
48
|
+
itemSize: ControlSize,
|
|
49
|
+
): ControlSize => {
|
|
50
|
+
// Both hooks run unconditionally and the *narrower* one wins, so
|
|
51
|
+
// the two queries never disagree about which step applies.
|
|
52
|
+
const { isMatching: isShortViewport } = useMediaQuery({
|
|
53
|
+
matcher: matchMediaMatcher(SHORT_VIEWPORT_QUERY),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const { isMatching: isVeryShortViewport } = useMediaQuery(
|
|
57
|
+
{
|
|
58
|
+
matcher: matchMediaMatcher(VERY_SHORT_VIEWPORT_QUERY),
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if (isVeryShortViewport) {
|
|
63
|
+
return "sm"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isShortViewport) {
|
|
67
|
+
return STEP_DOWN[itemSize]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return itemSize
|
|
71
|
+
}
|
package/src/controlStyles.ts
CHANGED
|
@@ -38,6 +38,44 @@ export const CONTROL_SIZE_CLASS: Record<
|
|
|
38
38
|
lg: "h-(--control-height-lg) gap-(--control-gap-lg) px-(--control-padding-inline-lg) text-lg",
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A row inside an overlay panel — a `Menu` item, a `Listbox` option,
|
|
43
|
+
* a `Combobox` option. The same three sizes as a control, read from
|
|
44
|
+
* the same density-aware tokens, so a `md` option row and a `md`
|
|
45
|
+
* `Button` are the same height.
|
|
46
|
+
*
|
|
47
|
+
* ### Why this is not `CONTROL_SIZE_CLASS`
|
|
48
|
+
*
|
|
49
|
+
* Two differences, and both are load-bearing.
|
|
50
|
+
*
|
|
51
|
+
* `min-h-`, not `h-`. A control on a form row holds one line by
|
|
52
|
+
* contract; an option may be rich — an icon, two lines, a trailing
|
|
53
|
+
* badge — and a fixed height clips it. The token is a **floor** here,
|
|
54
|
+
* which is what makes the row at least as big as the button that
|
|
55
|
+
* opened it while still growing for a label that wraps.
|
|
56
|
+
*
|
|
57
|
+
* `py-*`, which a control has none of. It is what a wrapped second
|
|
58
|
+
* line breathes on once `min-h-` stops being the binding constraint.
|
|
59
|
+
*
|
|
60
|
+
* ### The sizes are click targets, not decoration
|
|
61
|
+
*
|
|
62
|
+
* The owner's request is the whole reason this exists: *"I personally
|
|
63
|
+
* like larger click areas because it makes it easier to not mess up a
|
|
64
|
+
* click. I can be much faster the less precise I need to be when
|
|
65
|
+
* clicking."* At `comfortable` density `lg` is 2.75rem — 44px, the
|
|
66
|
+
* WCAG 2.5.5 target — and on the kiosk density it is 3.75rem with no
|
|
67
|
+
* prop change. See
|
|
68
|
+
* `docs/decisions/2026-08-25-a-panel-row-is-sized-by-itemsize-and-menus-default-to-lg.md`.
|
|
69
|
+
*/
|
|
70
|
+
export const PANEL_ITEM_SIZE_CLASS: Record<
|
|
71
|
+
ControlSize,
|
|
72
|
+
string
|
|
73
|
+
> = {
|
|
74
|
+
sm: "min-h-(--control-height-sm) gap-(--control-gap-sm) px-(--control-padding-inline-sm) py-1 text-sm",
|
|
75
|
+
md: "min-h-(--control-height-md) gap-(--control-gap-md) px-(--control-padding-inline-md) py-1.5 text-md",
|
|
76
|
+
lg: "min-h-(--control-height-lg) gap-(--control-gap-lg) px-(--control-padding-inline-lg) py-2 text-lg",
|
|
77
|
+
}
|
|
78
|
+
|
|
41
79
|
/**
|
|
42
80
|
* Square, and sized by the *height* token on both axes so an icon
|
|
43
81
|
* button lines up with the text button beside it in a toolbar. No
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
6
|
+
import * as stories from "./MarkdownEditorCodeMirror.stories.tsx"
|
|
7
|
+
|
|
8
|
+
const { AllStates, CaretReveal, Default } =
|
|
9
|
+
composeStories(stories)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The two ways this surface stopped behaving like the `<textarea>`
|
|
13
|
+
* it replaced. Both are invisible to every other gate: the stories
|
|
14
|
+
* render, the toolbar works, axe is clean, and the field is still
|
|
15
|
+
* one you cannot see the caret in and cannot click into.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every `caret-color` this element is actually told, in cascade
|
|
20
|
+
* order, ignoring the suite's own override.
|
|
21
|
+
*
|
|
22
|
+
* A computed style cannot answer this question **here**:
|
|
23
|
+
* `FREEZE_MOTION_CSS` forces `caret-color: transparent !important`
|
|
24
|
+
* on `*` for the whole `ui-dom` suite, deliberately, because a
|
|
25
|
+
* blinking caret is a pixel diff that means nothing. So the test
|
|
26
|
+
* reads the declarations instead — which is the honest subject
|
|
27
|
+
* anyway, since what regressed is *whose rule wins*.
|
|
28
|
+
*/
|
|
29
|
+
const caretDeclarationsFor = (element: Element) => {
|
|
30
|
+
const declarations: string[] = []
|
|
31
|
+
|
|
32
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
33
|
+
let rules: CSSRuleList | null = null
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
rules = sheet.cssRules
|
|
37
|
+
} catch {
|
|
38
|
+
// A cross-origin sheet throws on access and has nothing to
|
|
39
|
+
// say about a CodeMirror caret.
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const rule of Array.from(rules ?? [])) {
|
|
44
|
+
if (!(rule instanceof CSSStyleRule)) {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const value =
|
|
49
|
+
rule.style.getPropertyValue("caret-color")
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
value === "" ||
|
|
53
|
+
// The suite's own freeze, which is not part of the
|
|
54
|
+
// component's cascade.
|
|
55
|
+
rule.selectorText.includes("*")
|
|
56
|
+
) {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (element.matches(rule.selectorText)) {
|
|
61
|
+
declarations.push(value)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return declarations
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* CodeMirror hard-codes the caret **black** for any editor whose
|
|
71
|
+
* theme did not declare itself dark — and Charcuterie's is one
|
|
72
|
+
* theme for both schemes, because every colour in it is a token. So
|
|
73
|
+
* the editor is always `cm-light`, and in dark mode the caret was
|
|
74
|
+
* black on `surface-base`:
|
|
75
|
+
*
|
|
76
|
+
* *"this comments area has a black blinking cursor in dark mode
|
|
77
|
+
* that I can't see."*
|
|
78
|
+
*
|
|
79
|
+
* It is `caret-color` and not `.cm-cursor` because `drawSelection`
|
|
80
|
+
* is **not** installed on this surface: the caret is the browser's
|
|
81
|
+
* own, so the property the browser reads is the only lever. Last
|
|
82
|
+
* declaration wins at equal specificity — two classes each — and
|
|
83
|
+
* the base theme is `Prec.lowest`, which is what puts it first in
|
|
84
|
+
* the sheet.
|
|
85
|
+
*/
|
|
86
|
+
test("the caret is a token, not CodeMirror's black", async () => {
|
|
87
|
+
const { canvas } = await mountStory(CaretReveal)
|
|
88
|
+
|
|
89
|
+
const content = canvas.getByRole("textbox", {
|
|
90
|
+
name: "Description",
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const declarations = caretDeclarationsFor(content)
|
|
94
|
+
|
|
95
|
+
await expect(declarations.at(-1)).toBe(
|
|
96
|
+
"var(--color-content-primary)",
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The whole box takes a click, because the whole box IS the text
|
|
102
|
+
* field:
|
|
103
|
+
*
|
|
104
|
+
* *"the markdown box isn't a textarea, it's an input field or
|
|
105
|
+
* something. It should be the whole box that's clickable to type
|
|
106
|
+
* into."*
|
|
107
|
+
*
|
|
108
|
+
* `.cm-content` is the `contenteditable`, and it used to be as tall
|
|
109
|
+
* as its text — one line, in an empty field inside an 8rem frame.
|
|
110
|
+
* Everything below it belonged to the wrapper `<div>`, so the
|
|
111
|
+
* bottom three quarters of a text field focused nothing.
|
|
112
|
+
*
|
|
113
|
+
* The number is the frame's own minimum (`min-h-32`), so this fails
|
|
114
|
+
* on any change that puts the minimum back on a box the caret does
|
|
115
|
+
* not live in.
|
|
116
|
+
*/
|
|
117
|
+
test("an empty field's text area fills the frame", async () => {
|
|
118
|
+
const { canvas } = await mountStory(AllStates)
|
|
119
|
+
|
|
120
|
+
const content = canvas.getByRole("textbox", {
|
|
121
|
+
name: "Empty description",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(
|
|
126
|
+
content.getBoundingClientRect().height,
|
|
127
|
+
).toBeGreaterThanOrEqual(128)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
/** And the point three quarters of the way down an empty field
|
|
132
|
+
* belongs to the document rather than to the frame around it. */
|
|
133
|
+
test("a click low in an empty field lands in the document", async () => {
|
|
134
|
+
const { canvas } = await mountStory(AllStates)
|
|
135
|
+
|
|
136
|
+
const content = canvas.getByRole("textbox", {
|
|
137
|
+
name: "Empty description",
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const box = content.getBoundingClientRect()
|
|
141
|
+
|
|
142
|
+
const hit = document.elementFromPoint(
|
|
143
|
+
box.left + box.width / 2,
|
|
144
|
+
box.bottom - 8,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
await expect(content.contains(hit)).toBe(true)
|
|
148
|
+
|
|
149
|
+
await userEvent.click(content)
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
content === document.activeElement ||
|
|
153
|
+
content.contains(document.activeElement),
|
|
154
|
+
).toBe(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The other end of the same change: filling the frame must not
|
|
159
|
+
* turn the frame into a scroll box.
|
|
160
|
+
*
|
|
161
|
+
* A description is read in place, and an editor that keeps its own
|
|
162
|
+
* 8rem window over a 40-line document is the shape this surface
|
|
163
|
+
* exists to avoid. The frame grows instead — which is what
|
|
164
|
+
* `min-height: auto` on a flex item preserves, and what a `height`
|
|
165
|
+
* anywhere in this stack would have quietly taken away.
|
|
166
|
+
*/
|
|
167
|
+
test("a document taller than the frame grows it", async () => {
|
|
168
|
+
const { canvas } = await mountStory(Default)
|
|
169
|
+
|
|
170
|
+
const content = canvas.getByRole("textbox", {
|
|
171
|
+
name: "Description",
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const scroller = content.parentElement as HTMLElement
|
|
175
|
+
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(
|
|
178
|
+
content.getBoundingClientRect().height,
|
|
179
|
+
).toBeGreaterThan(128)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await expect(scroller.scrollHeight).toBe(
|
|
183
|
+
scroller.clientHeight,
|
|
184
|
+
)
|
|
185
|
+
})
|
|
@@ -834,8 +834,49 @@ export const MarkdownEditorCodeMirror = ({
|
|
|
834
834
|
/>
|
|
835
835
|
</div>
|
|
836
836
|
|
|
837
|
+
{/*
|
|
838
|
+
The frame's height reaches the `contenteditable`, so the
|
|
839
|
+
whole box takes a click.
|
|
840
|
+
|
|
841
|
+
A `<textarea>` is one box: it is as tall as you make it and
|
|
842
|
+
every pixel of it accepts the caret. CodeMirror is a stack
|
|
843
|
+
— `.cm-editor` in this host, `.cm-scroller` in that, and
|
|
844
|
+
the `contenteditable` `.cm-content` in that — and each of
|
|
845
|
+
those was only as tall as the text. A frame with a
|
|
846
|
+
`min-height` and a two-line document therefore had a large
|
|
847
|
+
band that belonged to *this div*, and pressing it focused
|
|
848
|
+
nothing:
|
|
849
|
+
|
|
850
|
+
*"the markdown box isn't a textarea, it's an input field
|
|
851
|
+
or something. It should be the whole box that's clickable
|
|
852
|
+
to type into."*
|
|
853
|
+
|
|
854
|
+
So the height is handed down the stack, one utility per
|
|
855
|
+
step, and each one is there because the step above it does
|
|
856
|
+
not arrive on its own:
|
|
857
|
+
|
|
858
|
+
1. `flex flex-col` + `[&>.cm-editor]:flex-1` — a
|
|
859
|
+
`min-height` is not a definite height, so nothing
|
|
860
|
+
inside the editor could be a percentage *of* it.
|
|
861
|
+
2. `[&_.cm-scroller]:flex-1` — CodeMirror's base theme
|
|
862
|
+
gives the scroller `height: 100%`, and that percentage
|
|
863
|
+
does not resolve against a parent whose own height came
|
|
864
|
+
out of flexing. Stretching it by flex does.
|
|
865
|
+
3. `[&_.cm-content]:self-stretch` — the scroller is a flex
|
|
866
|
+
ROW pinned to `align-items: flex-start !important`, so
|
|
867
|
+
the content sits at its natural height. `align-self` is
|
|
868
|
+
the item's own property and is not what that
|
|
869
|
+
`!important` is on, which is why this is the one line
|
|
870
|
+
that reaches the box the caret actually lives in.
|
|
871
|
+
|
|
872
|
+
Everything here grows, and nothing clips: a document taller
|
|
873
|
+
than the frame still makes the frame taller rather than
|
|
874
|
+
scrolling inside it, which is what `min-height: auto` on a
|
|
875
|
+
flex item preserves. Measured both ways in
|
|
876
|
+
`MarkdownEditorCodeMirror.test.tsx`.
|
|
877
|
+
*/}
|
|
837
878
|
<div
|
|
838
|
-
className="min-h-32 px-1 text-md cq-md:min-h-48"
|
|
879
|
+
className="flex min-h-32 flex-col px-1 text-md cq-md:min-h-48 [&>.cm-editor]:flex-1 [&_.cm-scroller]:flex-1 [&_.cm-content]:self-stretch"
|
|
839
880
|
ref={hostRef}
|
|
840
881
|
/>
|
|
841
882
|
|
|
@@ -50,6 +50,29 @@ export type MarkdownViewProps = SlotProps & {
|
|
|
50
50
|
* back.
|
|
51
51
|
*/
|
|
52
52
|
onToggleTask?: (nextValue: string) => void
|
|
53
|
+
/**
|
|
54
|
+
* Re-point a link before it becomes an `href`, for a document
|
|
55
|
+
* whose paths mean something only the app knows.
|
|
56
|
+
*
|
|
57
|
+
* The case it exists for is a **relative** path in a fetched or
|
|
58
|
+
* imported file. `[the runbook](../docs/runbook.md)` is resolved
|
|
59
|
+
* by the browser against the page the reader is on — the app —
|
|
60
|
+
* so it lands on nothing. Return the URL it should point at
|
|
61
|
+
* instead, or `undefined` to leave the link exactly as written.
|
|
62
|
+
*
|
|
63
|
+
* The document's own URL has already been through the scheme
|
|
64
|
+
* guard when it arrives, so `javascript:` never reaches this.
|
|
65
|
+
* What comes back is the app's own string and is used as given.
|
|
66
|
+
*
|
|
67
|
+
* **Links only.** An image's `src` is deliberately not offered:
|
|
68
|
+
* an app that maps a document path to a page URL would turn a
|
|
69
|
+
* working image into a broken one.
|
|
70
|
+
*
|
|
71
|
+
* Read once, at mount, through a ref — so a resolver defined
|
|
72
|
+
* inline in a render does not rebuild the view and lose the
|
|
73
|
+
* reader's scroll position.
|
|
74
|
+
*/
|
|
75
|
+
resolveUrl?: (url: string) => string | undefined
|
|
53
76
|
/**
|
|
54
77
|
* The markdown to render, and **controlled** — unlike either
|
|
55
78
|
* editor.
|
|
@@ -170,6 +193,7 @@ export const MarkdownView = ({
|
|
|
170
193
|
className,
|
|
171
194
|
label,
|
|
172
195
|
onToggleTask,
|
|
196
|
+
resolveUrl,
|
|
173
197
|
value,
|
|
174
198
|
...receivedSlotProps
|
|
175
199
|
}: MarkdownViewProps): ReactNode => {
|
|
@@ -193,6 +217,16 @@ export const MarkdownView = ({
|
|
|
193
217
|
|
|
194
218
|
handlerRef.current = onToggleTask
|
|
195
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Same treatment, and for the same reason: a resolver written
|
|
222
|
+
* inline in the consumer's render is a new function every time,
|
|
223
|
+
* and rebuilding the view on it would throw away the scroll
|
|
224
|
+
* position of whatever the reader was reading.
|
|
225
|
+
*/
|
|
226
|
+
const resolveUrlRef = useRef(resolveUrl)
|
|
227
|
+
|
|
228
|
+
resolveUrlRef.current = resolveUrl
|
|
229
|
+
|
|
196
230
|
/**
|
|
197
231
|
* Frozen at mount, and named so rather than read from the render
|
|
198
232
|
* closure, because the build-once effect below is honestly
|
|
@@ -224,7 +258,12 @@ export const MarkdownView = ({
|
|
|
224
258
|
// component exists to avoid.
|
|
225
259
|
markdown({ base: markdownLanguage }),
|
|
226
260
|
EditorView.lineWrapping,
|
|
227
|
-
livePreview({
|
|
261
|
+
livePreview({
|
|
262
|
+
hasDocumentSemantics: true,
|
|
263
|
+
// Stable by construction — the ref is what changes.
|
|
264
|
+
resolveUrl: (url) =>
|
|
265
|
+
resolveUrlRef.current?.(url),
|
|
266
|
+
}),
|
|
228
267
|
/**
|
|
229
268
|
* The one answer that can change after mount, and only
|
|
230
269
|
* that answer.
|
|
@@ -104,6 +104,13 @@ export type LivePreviewOptions = {
|
|
|
104
104
|
hasDocumentSemantics?: boolean
|
|
105
105
|
/** Whether a `- [ ]` checkbox can be ticked. Defaults to `true`. */
|
|
106
106
|
isTaskListInteractive?: boolean
|
|
107
|
+
/**
|
|
108
|
+
* Re-point a link before it becomes an `href` — see
|
|
109
|
+
* `ToLivePreviewRangesOptions.resolveUrl`, which this is handed
|
|
110
|
+
* to. Links only, and the document's URL has already been through
|
|
111
|
+
* the scheme guard when it arrives.
|
|
112
|
+
*/
|
|
113
|
+
resolveUrl?: (url: string) => string | undefined
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
/**
|
|
@@ -130,10 +137,12 @@ export const livePreviewOptions = Facet.define<
|
|
|
130
137
|
isTaskListInteractive:
|
|
131
138
|
value.isTaskListInteractive ??
|
|
132
139
|
merged.isTaskListInteractive,
|
|
140
|
+
resolveUrl: value.resolveUrl ?? merged.resolveUrl,
|
|
133
141
|
}),
|
|
134
142
|
{
|
|
135
143
|
hasDocumentSemantics: false,
|
|
136
144
|
isTaskListInteractive: true,
|
|
145
|
+
resolveUrl: () => undefined,
|
|
137
146
|
},
|
|
138
147
|
),
|
|
139
148
|
})
|
|
@@ -672,8 +681,11 @@ const toDecorations = (view: EditorView): DecorationSet => {
|
|
|
672
681
|
false,
|
|
673
682
|
)
|
|
674
683
|
|
|
675
|
-
const {
|
|
676
|
-
|
|
684
|
+
const {
|
|
685
|
+
hasDocumentSemantics,
|
|
686
|
+
isTaskListInteractive,
|
|
687
|
+
resolveUrl,
|
|
688
|
+
} = toOptions(view.state)
|
|
677
689
|
|
|
678
690
|
const text = view.state.doc.toString()
|
|
679
691
|
|
|
@@ -709,6 +721,7 @@ const toDecorations = (view: EditorView): DecorationSet => {
|
|
|
709
721
|
for (const range of toLivePreviewRanges({
|
|
710
722
|
from: visible.from,
|
|
711
723
|
isRawMode: isRawMode ?? false,
|
|
724
|
+
resolveUrl,
|
|
712
725
|
selections,
|
|
713
726
|
text,
|
|
714
727
|
to: visible.to,
|
|
@@ -838,7 +851,8 @@ const toTableDecorations = (state: EditorState) => {
|
|
|
838
851
|
false,
|
|
839
852
|
)
|
|
840
853
|
|
|
841
|
-
const { hasDocumentSemantics } =
|
|
854
|
+
const { hasDocumentSemantics, resolveUrl } =
|
|
855
|
+
toOptions(state)
|
|
842
856
|
|
|
843
857
|
const selections =
|
|
844
858
|
state.field(isEditorFocusedField, false) &&
|
|
@@ -854,6 +868,7 @@ const toTableDecorations = (state: EditorState) => {
|
|
|
854
868
|
return Decoration.set(
|
|
855
869
|
toLivePreviewTableRanges({
|
|
856
870
|
isRawMode: isRawMode ?? false,
|
|
871
|
+
resolveUrl,
|
|
857
872
|
selections,
|
|
858
873
|
text,
|
|
859
874
|
tree: syntaxTree(state),
|
|
@@ -1138,6 +1153,28 @@ const livePreviewTheme = EditorView.theme({
|
|
|
1138
1153
|
* it.
|
|
1139
1154
|
*/
|
|
1140
1155
|
".cm-content": {
|
|
1156
|
+
/**
|
|
1157
|
+
* The caret, which CodeMirror hard-codes **black**.
|
|
1158
|
+
*
|
|
1159
|
+
* `drawSelection` is not installed, so the caret is the
|
|
1160
|
+
* browser's own and `caret-color` decides what it looks like.
|
|
1161
|
+
* The base theme sets that per light/dark class — `&light .cm-
|
|
1162
|
+
* content { caret-color: black }` — and those classes track
|
|
1163
|
+
* whether a *CodeMirror theme* declared `dark: true`, not what
|
|
1164
|
+
* scheme the page is painted in. Charcuterie has one theme for
|
|
1165
|
+
* both schemes because every colour in it is a token, so the
|
|
1166
|
+
* editor is always `cm-light` and the caret was always black:
|
|
1167
|
+
* invisible on `surface-base` in dark mode, which is a text
|
|
1168
|
+
* field you cannot find the insertion point in.
|
|
1169
|
+
*
|
|
1170
|
+
* The token is the same one the text uses, so it follows the
|
|
1171
|
+
* scheme by construction and cannot be black on black again.
|
|
1172
|
+
* Same specificity as the base rule it replaces (two classes),
|
|
1173
|
+
* and later in the sheet — a theme's rules are inserted after
|
|
1174
|
+
* the base theme's, which is what CodeMirror's `Prec.lowest`
|
|
1175
|
+
* on the base module guarantees.
|
|
1176
|
+
*/
|
|
1177
|
+
caretColor: "var(--color-content-primary)",
|
|
1141
1178
|
lineHeight: "1.6",
|
|
1142
1179
|
},
|
|
1143
1180
|
/**
|
|
@@ -1003,3 +1003,140 @@ describe("toLivePreviewRanges", () => {
|
|
|
1003
1003
|
})
|
|
1004
1004
|
})
|
|
1005
1005
|
})
|
|
1006
|
+
|
|
1007
|
+
describe("resolveUrl", () => {
|
|
1008
|
+
const toResolved = (
|
|
1009
|
+
text: string,
|
|
1010
|
+
resolveUrl: (url: string) => string | undefined,
|
|
1011
|
+
) =>
|
|
1012
|
+
toLivePreviewRanges({
|
|
1013
|
+
isRawMode: false,
|
|
1014
|
+
resolveUrl,
|
|
1015
|
+
selections: [],
|
|
1016
|
+
text,
|
|
1017
|
+
tree: markdownLanguage.parser.parse(text),
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* The case the option exists for: a relative path in a document
|
|
1022
|
+
* the app fetched from somewhere else. The browser resolves it
|
|
1023
|
+
* against the page the reader is ON, which is the app, so it
|
|
1024
|
+
* lands on nothing. Only the app knows what the path means.
|
|
1025
|
+
*/
|
|
1026
|
+
test("re-points a link the consumer claims", () => {
|
|
1027
|
+
const text = "see [the runbook](../docs/runbook.md)"
|
|
1028
|
+
|
|
1029
|
+
const linkText = toRanges(text).find(
|
|
1030
|
+
(range) =>
|
|
1031
|
+
range.type === "mark" &&
|
|
1032
|
+
range.markKind === "linkText",
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
expect(linkText).toMatchObject({
|
|
1036
|
+
url: "../docs/runbook.md",
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
const resolved = toResolved(text, (url) =>
|
|
1040
|
+
url.startsWith("../")
|
|
1041
|
+
? `/files?path=${url.slice(3)}`
|
|
1042
|
+
: undefined,
|
|
1043
|
+
).find(
|
|
1044
|
+
(range) =>
|
|
1045
|
+
range.type === "mark" &&
|
|
1046
|
+
range.markKind === "linkText",
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
expect(resolved).toMatchObject({
|
|
1050
|
+
url: "/files?path=docs/runbook.md",
|
|
1051
|
+
})
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
test("leaves a link alone when the consumer returns undefined", () => {
|
|
1055
|
+
const text = "see [the docs](https://example.invalid/a)"
|
|
1056
|
+
|
|
1057
|
+
expect(
|
|
1058
|
+
toResolved(text, () => undefined).find(
|
|
1059
|
+
(range) =>
|
|
1060
|
+
range.type === "mark" &&
|
|
1061
|
+
range.markKind === "linkText",
|
|
1062
|
+
),
|
|
1063
|
+
).toMatchObject({ url: "https://example.invalid/a" })
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
test("re-points a bare URL too", () => {
|
|
1067
|
+
const text = "see https://example.invalid/a for more"
|
|
1068
|
+
|
|
1069
|
+
expect(
|
|
1070
|
+
toResolved(text, () => "/elsewhere").find(
|
|
1071
|
+
(range) =>
|
|
1072
|
+
range.type === "mark" &&
|
|
1073
|
+
range.markKind === "autolink",
|
|
1074
|
+
),
|
|
1075
|
+
).toMatchObject({ url: "/elsewhere" })
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The guard runs FIRST, so a refused URL never reaches the
|
|
1080
|
+
* consumer — it is not a link at all by then, and offering an app
|
|
1081
|
+
* the chance to resurrect one would undo `safeUrls.ts`.
|
|
1082
|
+
*/
|
|
1083
|
+
test("never offers a URL the scheme guard refused", () => {
|
|
1084
|
+
const seen: string[] = []
|
|
1085
|
+
|
|
1086
|
+
const text = "[click me](javascript:alert(1))"
|
|
1087
|
+
|
|
1088
|
+
toResolved(text, (url) => {
|
|
1089
|
+
seen.push(url)
|
|
1090
|
+
|
|
1091
|
+
return undefined
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
expect(seen).toEqual([])
|
|
1095
|
+
})
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* An image is deliberately not offered. An app that maps a
|
|
1099
|
+
* document path to a PAGE url would turn a working image into a
|
|
1100
|
+
* broken one, and the image's `src` is the one URL here that has
|
|
1101
|
+
* to stay fetchable.
|
|
1102
|
+
*/
|
|
1103
|
+
test("does not offer an image's src", () => {
|
|
1104
|
+
const seen: string[] = []
|
|
1105
|
+
|
|
1106
|
+
const text = ""
|
|
1107
|
+
|
|
1108
|
+
const image = toResolved(text, (url) => {
|
|
1109
|
+
seen.push(url)
|
|
1110
|
+
|
|
1111
|
+
return "/elsewhere.png"
|
|
1112
|
+
}).find((range) => range.type === "image")
|
|
1113
|
+
|
|
1114
|
+
expect(seen).toEqual([])
|
|
1115
|
+
|
|
1116
|
+
expect(image).toMatchObject({
|
|
1117
|
+
url: "../docs/a.png",
|
|
1118
|
+
})
|
|
1119
|
+
})
|
|
1120
|
+
|
|
1121
|
+
test("re-points a link inside a table cell", () => {
|
|
1122
|
+
const text = [
|
|
1123
|
+
"| Port | Notes |",
|
|
1124
|
+
"| --- | --- |",
|
|
1125
|
+
"| 1 | see [the runbook](../docs/runbook.md) |",
|
|
1126
|
+
].join("\n")
|
|
1127
|
+
|
|
1128
|
+
const cell = toLivePreviewTableRanges({
|
|
1129
|
+
isRawMode: false,
|
|
1130
|
+
resolveUrl: () => "/files?path=docs/runbook.md",
|
|
1131
|
+
selections: [],
|
|
1132
|
+
text,
|
|
1133
|
+
tree: markdownLanguage.parser.parse(text),
|
|
1134
|
+
})[0]?.rows[1]?.cells[1]
|
|
1135
|
+
|
|
1136
|
+
expect(toFound(cell).segments).toContainEqual(
|
|
1137
|
+
expect.objectContaining({
|
|
1138
|
+
url: "/files?path=docs/runbook.md",
|
|
1139
|
+
}),
|
|
1140
|
+
)
|
|
1141
|
+
})
|
|
1142
|
+
})
|