@charcuterie/ui 3.18.0 → 3.19.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.
- package/dist/Combobox/Combobox.d.ts +14 -1
- package/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +81 -6
- 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/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +22 -0
- package/dist/markdownEditorCodeMirror/livePreview.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 +1 -1
- package/src/Combobox/Combobox.mdx +26 -0
- package/src/Combobox/Combobox.stories.tsx +65 -0
- package/src/Combobox/Combobox.test.tsx +109 -0
- package/src/Combobox/Combobox.tsx +140 -25
- 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/livePreview.ts +22 -0
- package/src/sourceRules.test.ts +9 -0
- package/src/viewport.testHelpers.ts +21 -6
|
@@ -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
|
|
|
@@ -1153,6 +1153,28 @@ const livePreviewTheme = EditorView.theme({
|
|
|
1153
1153
|
* it.
|
|
1154
1154
|
*/
|
|
1155
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)",
|
|
1156
1178
|
lineHeight: "1.6",
|
|
1157
1179
|
},
|
|
1158
1180
|
/**
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -613,6 +613,15 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
613
613
|
// and stays the default.
|
|
614
614
|
"./markdown-editor-codemirror": [
|
|
615
615
|
"@charcuterie/logic",
|
|
616
|
+
// The editor's toolbar renders a `Menu`, and every panel row now
|
|
617
|
+
// asks `usePanelItemSize` whether the window is short enough to
|
|
618
|
+
// step down — which is a `matchMedia` read, and `matchMedia` is
|
|
619
|
+
// behind this subpath by the same rule that keeps it out of
|
|
620
|
+
// `@charcuterie/logic`'s main entry. The main `ui` entry already
|
|
621
|
+
// reached it (`ColorSchemeSwitcher`); this one now does too. It is
|
|
622
|
+
// ~1 KB of feature-detected wrapper, not a runtime the subpath
|
|
623
|
+
// exists to keep out — that is the `@codemirror/*` set below.
|
|
624
|
+
"@charcuterie/logic/browser",
|
|
616
625
|
"@charcuterie/tokens",
|
|
617
626
|
"@codemirror/commands",
|
|
618
627
|
"@codemirror/lang-markdown",
|
|
@@ -12,10 +12,15 @@ import { page } from "@vitest/browser/context"
|
|
|
12
12
|
* fails intermittently — which is the worst possible property for
|
|
13
13
|
* the one gate that says the shell does not scroll sideways.
|
|
14
14
|
*
|
|
15
|
-
* Polling `innerWidth` is the honest wait:
|
|
16
|
-
* the media queries and the layout resolved against, so
|
|
17
|
-
*
|
|
18
|
-
* claims.
|
|
15
|
+
* Polling `innerWidth`/`innerHeight` is the honest wait: they are the
|
|
16
|
+
* same numbers the media queries and the layout resolved against, so
|
|
17
|
+
* when they match, the thing under test really is at the size the
|
|
18
|
+
* test claims.
|
|
19
|
+
*
|
|
20
|
+
* **Height counts too**, and it did not used to. A panel row's size
|
|
21
|
+
* steps down under `(height <= 40rem)` (`usePanelItemSize`), so a test
|
|
22
|
+
* that shrinks only the height would have raced the resize with
|
|
23
|
+
* nothing to wait on and measured the tall rows about half the time.
|
|
19
24
|
*/
|
|
20
25
|
export const setViewport = async ({
|
|
21
26
|
height,
|
|
@@ -28,10 +33,13 @@ export const setViewport = async ({
|
|
|
28
33
|
|
|
29
34
|
const deadline = Date.now() + 2000
|
|
30
35
|
|
|
31
|
-
while (
|
|
36
|
+
while (
|
|
37
|
+
globalThis.innerWidth !== width ||
|
|
38
|
+
globalThis.innerHeight !== height
|
|
39
|
+
) {
|
|
32
40
|
if (Date.now() > deadline) {
|
|
33
41
|
throw new Error(
|
|
34
|
-
`The viewport never reached ${width}px — it is ${globalThis.innerWidth}px. Every
|
|
42
|
+
`The viewport never reached ${width}x${height}px — it is ${globalThis.innerWidth}x${globalThis.innerHeight}px. Every size assertion after this point would be measuring the wrong page.`,
|
|
35
43
|
)
|
|
36
44
|
}
|
|
37
45
|
|
|
@@ -45,3 +53,10 @@ export const setViewport = async ({
|
|
|
45
53
|
export const PHONE = { height: 844, width: 390 }
|
|
46
54
|
|
|
47
55
|
export const DESKTOP = { height: 900, width: 1440 }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A window short enough to trip `usePanelItemSize`'s first step-down
|
|
59
|
+
* (`height <= 40rem`, so 640px) and wide enough that nothing else
|
|
60
|
+
* about the layout changes with it.
|
|
61
|
+
*/
|
|
62
|
+
export const SHORT_WINDOW = { height: 600, width: 1440 }
|