@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.
Files changed (69) hide show
  1. package/dist/Combobox/Combobox.d.ts +14 -1
  2. package/dist/Combobox/Combobox.d.ts.map +1 -1
  3. package/dist/Combobox/Combobox.js +81 -6
  4. package/dist/Combobox/Combobox.js.map +1 -1
  5. package/dist/Combobox/ComboboxOption.d.ts +4 -1
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  7. package/dist/Combobox/ComboboxOption.js +8 -2
  8. package/dist/Combobox/ComboboxOption.js.map +1 -1
  9. package/dist/Listbox/Listbox.d.ts +13 -1
  10. package/dist/Listbox/Listbox.d.ts.map +1 -1
  11. package/dist/Listbox/Listbox.js +4 -2
  12. package/dist/Listbox/Listbox.js.map +1 -1
  13. package/dist/Listbox/ListboxOption.d.ts +4 -1
  14. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  15. package/dist/Listbox/ListboxOption.js +7 -2
  16. package/dist/Listbox/ListboxOption.js.map +1 -1
  17. package/dist/Menu/Menu.d.ts +15 -1
  18. package/dist/Menu/Menu.d.ts.map +1 -1
  19. package/dist/Menu/Menu.js +39 -5
  20. package/dist/Menu/Menu.js.map +1 -1
  21. package/dist/Menu/MenuAction.d.ts +4 -1
  22. package/dist/Menu/MenuAction.d.ts.map +1 -1
  23. package/dist/Menu/MenuAction.js +22 -4
  24. package/dist/Menu/MenuAction.js.map +1 -1
  25. package/dist/Overlay/useAnchoredOverlay.d.ts +25 -1
  26. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  27. package/dist/Overlay/useAnchoredOverlay.js +8 -4
  28. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  29. package/dist/Overlay/usePanelItemSize.d.ts +3 -0
  30. package/dist/Overlay/usePanelItemSize.d.ts.map +1 -0
  31. package/dist/Overlay/usePanelItemSize.js +59 -0
  32. package/dist/Overlay/usePanelItemSize.js.map +1 -0
  33. package/dist/controlStyles.d.ts +30 -0
  34. package/dist/controlStyles.d.ts.map +1 -1
  35. package/dist/controlStyles.js +34 -0
  36. package/dist/controlStyles.js.map +1 -1
  37. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  38. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
  39. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  40. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  41. package/dist/markdownEditorCodeMirror/livePreview.js +22 -0
  42. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  43. package/dist/viewport.testHelpers.d.ts +18 -4
  44. package/dist/viewport.testHelpers.d.ts.map +1 -1
  45. package/dist/viewport.testHelpers.js +18 -6
  46. package/dist/viewport.testHelpers.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/Combobox/Combobox.mdx +26 -0
  49. package/src/Combobox/Combobox.stories.tsx +65 -0
  50. package/src/Combobox/Combobox.test.tsx +109 -0
  51. package/src/Combobox/Combobox.tsx +140 -25
  52. package/src/Combobox/ComboboxOption.tsx +11 -1
  53. package/src/Listbox/Listbox.mdx +24 -0
  54. package/src/Listbox/Listbox.stories.tsx +81 -5
  55. package/src/Listbox/Listbox.tsx +17 -0
  56. package/src/Listbox/ListboxOption.tsx +10 -1
  57. package/src/Menu/Menu.mdx +49 -0
  58. package/src/Menu/Menu.stories.tsx +113 -2
  59. package/src/Menu/Menu.test.tsx +129 -0
  60. package/src/Menu/Menu.tsx +62 -3
  61. package/src/Menu/MenuAction.tsx +29 -4
  62. package/src/Overlay/useAnchoredOverlay.ts +33 -5
  63. package/src/Overlay/usePanelItemSize.ts +71 -0
  64. package/src/controlStyles.ts +38 -0
  65. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +185 -0
  66. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +42 -1
  67. package/src/markdownEditorCodeMirror/livePreview.ts +22 -0
  68. package/src/sourceRules.test.ts +9 -0
  69. 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
  /**
@@ -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: it is the same number
16
- * the media queries and the layout resolved against, so when it
17
- * matches, the thing under test really is at the width the test
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 (globalThis.innerWidth !== width) {
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 width assertion after this point would be measuring the wrong page.`,
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 }