@charcuterie/ui 3.19.2 → 3.21.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.
Files changed (48) hide show
  1. package/dist/Board/BoardCard.d.ts +95 -22
  2. package/dist/Board/BoardCard.d.ts.map +1 -1
  3. package/dist/Board/BoardCard.js +18 -2
  4. package/dist/Board/BoardCard.js.map +1 -1
  5. package/dist/MarkdownEditor/markdownSpans.d.ts +17 -4
  6. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
  7. package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
  8. package/dist/MarkdownLine/MarkdownLine.d.ts +104 -0
  9. package/dist/MarkdownLine/MarkdownLine.d.ts.map +1 -0
  10. package/dist/MarkdownLine/MarkdownLine.js +187 -0
  11. package/dist/MarkdownLine/MarkdownLine.js.map +1 -0
  12. package/dist/MarkdownLine/inlineMarkdown.d.ts +82 -0
  13. package/dist/MarkdownLine/inlineMarkdown.d.ts.map +1 -0
  14. package/dist/MarkdownLine/inlineMarkdown.js +509 -0
  15. package/dist/MarkdownLine/inlineMarkdown.js.map +1 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/markdownEditorCodeMirror/index.d.ts +1 -1
  21. package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -1
  22. package/dist/markdownEditorCodeMirror/index.js +1 -1
  23. package/dist/markdownEditorCodeMirror/index.js.map +1 -1
  24. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +1 -1
  25. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  26. package/dist/safeUrls.d.ts.map +1 -0
  27. package/dist/safeUrls.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/Board/Board.stories.tsx +167 -0
  30. package/src/Board/Board.test.tsx +97 -0
  31. package/src/Board/BoardCard.tsx +135 -27
  32. package/src/MarkdownEditor/markdownSpans.ts +18 -5
  33. package/src/MarkdownLine/MarkdownLine.mdx +135 -0
  34. package/src/MarkdownLine/MarkdownLine.stories.tsx +290 -0
  35. package/src/MarkdownLine/MarkdownLine.test.tsx +241 -0
  36. package/src/MarkdownLine/MarkdownLine.tsx +331 -0
  37. package/src/MarkdownLine/inlineMarkdown.test.ts +253 -0
  38. package/src/MarkdownLine/inlineMarkdown.ts +768 -0
  39. package/src/index.ts +9 -2
  40. package/src/markdownEditorCodeMirror/index.ts +4 -4
  41. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +1 -1
  42. package/src/sourceRules.test.ts +17 -1
  43. package/dist/markdownEditorCodeMirror/safeUrls.d.ts.map +0 -1
  44. package/dist/markdownEditorCodeMirror/safeUrls.js.map +0 -1
  45. /package/dist/{markdownEditorCodeMirror/safeUrls.d.ts → safeUrls.d.ts} +0 -0
  46. /package/dist/{markdownEditorCodeMirror/safeUrls.js → safeUrls.js} +0 -0
  47. /package/src/{markdownEditorCodeMirror/safeUrls.test.ts → safeUrls.test.ts} +0 -0
  48. /package/src/{markdownEditorCodeMirror/safeUrls.ts → safeUrls.ts} +0 -0
@@ -3,6 +3,11 @@ import type { ReactNode } from "react"
3
3
  import { useState } from "react"
4
4
 
5
5
  import { Button } from "../Button/Button.tsx"
6
+ import type { CardAccentEdge } from "../Card/cardAccentEdge.ts"
7
+ import {
8
+ getAccentEdgeClassName,
9
+ getAccentEdgeStyle,
10
+ } from "../Card/cardAccentEdge.ts"
6
11
  import {
7
12
  FOCUS_RING_CLASS,
8
13
  INTENT_SOLID_FILL_CLASS,
@@ -12,32 +17,14 @@ import { TextLink } from "../TextLink/TextLink.tsx"
12
17
  import { toClassName } from "../toClassName.ts"
13
18
  import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
14
19
 
15
- export type BoardItem = {
16
- /**
17
- * The leading colour bar. Priority, in the consumer this was built
18
- * for — *"Priority is styled as a leading colour bar, and **never**
19
- * a coloured word before the title."*
20
- *
21
- * Pair it with `accentLabel`. A bar is colour and nothing else, so
22
- * on its own it fails WCAG 1.4.1 outright and is invisible to
23
- * every screen reader; the label rides along in a
24
- * `VisuallyHidden` so the same fact reaches both channels. Docket's
25
- * first board painted these `--color-danger-9` — a Radix-style
26
- * scale this token set has never had — so every bar was
27
- * transparent while every "is it rendered" assertion passed. The
28
- * families here are `--color-intent-<intent>-<role>`; there is no
29
- * numeric scale to reach for.
30
- */
31
- accentIntent?: IntentName
32
- /** What the bar means, in words. "Priority 0". */
20
+ type BoardItemFields = {
21
+ /** What the bar means, in words. "Priority 0", "Movies and Shows". */
33
22
  accentLabel?: string
34
23
  /**
35
24
  * Under the title and metadata, spanning the card: a live run
36
25
  * line, a gate explanation, a due date. A `ProgressBar` goes here.
37
26
  */
38
27
  footer?: ReactNode
39
- /** Where the title navigates. Routed through `RouterLinkProvider`. */
40
- href?: string
41
28
  /**
42
29
  * Visibly present but not actionable — Docket's *gated*: a task
43
30
  * whose phase has not opened yet. Paint only; it does not disable
@@ -57,13 +44,105 @@ export type BoardItem = {
57
44
  * beside it when there is room. `Badge` is what goes here.
58
45
  */
59
46
  meta?: ReactNode
60
- /** Fires when there is no `href` — for a board that opens a modal. */
61
- onSelect?: () => void
47
+ /**
48
+ * The card's name, in **words**.
49
+ *
50
+ * Always a plain string, even when `titleContent` draws something
51
+ * richer, because this is what the move handle and the drag
52
+ * announcements are named after — and a control named after a
53
+ * `ReactNode` is a control with no name at all.
54
+ */
62
55
  title: string
63
56
  /** The trailing cluster: an assignee avatar, an elapsed-time chip. */
64
57
  trailing?: ReactNode
65
58
  }
66
59
 
60
+ /**
61
+ * The leading colour bar, in one of two shapes — and the second one
62
+ * exists because the first cannot follow a corner.
63
+ *
64
+ * `accentIntent` is an inset pill: a `w-1` span inside the card,
65
+ * beside the content. It was built for priority — *"Priority is
66
+ * styled as a leading colour bar, and **never** a coloured word
67
+ * before the title."* An intent is a claim the design system makes
68
+ * (`danger` says what happens if you press the thing), so this arm
69
+ * is for a bar that means a STATE.
70
+ *
71
+ * `accentEdge` is the card's own edge — `Card`'s treatment, the same
72
+ * pseudo-element, so it takes `border-radius: inherit` and wraps the
73
+ * corner at `cq-lg` where this card grows one. It carries a
74
+ * CATEGORICAL colour or a computed one, which is what an identity
75
+ * needs: a project, a repo, a source. The owner asked for exactly
76
+ * this on Docket's board — *"The Board has no colors. That could
77
+ * use project colors on the sides at least."* — after reporting the
78
+ * same treatment missing from the task rows.
79
+ *
80
+ * The two are mutually exclusive by type, not by convention. Both
81
+ * paint a leading bar in the same place, and a card wearing two of
82
+ * them is a card whose leading edge means two things.
83
+ *
84
+ * ⚠️ Either arm pairs with `accentLabel`. A bar is colour and
85
+ * nothing else, so on its own it fails WCAG 1.4.1 outright and is
86
+ * invisible to every screen reader; the label rides along in a
87
+ * `VisuallyHidden` so the same fact reaches both channels. Docket's
88
+ * first board painted its bars `--color-danger-9` — a Radix-style
89
+ * scale this token set has never had — so every bar was transparent
90
+ * while every "is it rendered" assertion passed.
91
+ */
92
+ type BoardItemAccent =
93
+ | { accentEdge?: never; accentIntent?: IntentName }
94
+ | { accentEdge: CardAccentEdge; accentIntent?: never }
95
+
96
+ /**
97
+ * Who owns the card title's link — the board, or the caller.
98
+ *
99
+ * A union rather than three loose optionals, because the two halves
100
+ * genuinely cannot coexist. `href` wraps the title in a `TextLink`
101
+ * and `onSelect` wraps it in a `<button>`; a `titleContent` that
102
+ * contains its own anchor would then be an anchor inside an anchor
103
+ * (which the HTML parser silently un-nests, dropping the rest of the
104
+ * card's link) or an anchor inside a button (which is invalid and
105
+ * unreachable by keyboard). Both look completely correct on screen.
106
+ *
107
+ * So the type refuses the combination outright, and a caller who
108
+ * needs a rich title takes the navigation with it:
109
+ *
110
+ * ```tsx
111
+ * {
112
+ * title: toPlainMarkdownText(task.title),
113
+ * titleContent: (
114
+ * <MarkdownLine href={`/tasks/${task.id}`} value={task.title} />
115
+ * ),
116
+ * }
117
+ * ```
118
+ */
119
+ type BoardItemTitleLink =
120
+ | {
121
+ /** Where the title navigates. Routed through `RouterLinkProvider`. */
122
+ href?: string
123
+ /** Fires when there is no `href` — for a board that opens a modal. */
124
+ onSelect?: () => void
125
+ titleContent?: never
126
+ }
127
+ | {
128
+ href?: never
129
+ onSelect?: never
130
+ /**
131
+ * Drawn instead of `title`, inside the card's own clamping box
132
+ * — so it still truncates the way every other card does.
133
+ *
134
+ * It **owns its navigation**: the board adds no link around it,
135
+ * which is why `href` and `onSelect` are refused beside it.
136
+ * `title` is still required, and is still the name every
137
+ * control on the card is announced by.
138
+ */
139
+ titleContent: ReactNode
140
+ }
141
+
142
+ export type BoardItem = BoardItemAccent &
143
+ BoardItemFields &
144
+ BoardItemTitleLink
145
+
67
146
  export type BoardCardProps = {
68
147
  item: BoardItem
69
148
  laneLabel: string
@@ -137,12 +216,15 @@ export const BoardCard = ({
137
216
  }: BoardCardProps): ReactNode => {
138
217
  const [isMenuVisible, setIsMenuVisible] = useState(false)
139
218
 
140
- const titleContent = (
219
+ const titleBlock = (
141
220
  // `line-clamp-none` before `truncate`, because `line-clamp-2`
142
221
  // sets `display: -webkit-box` and `text-overflow` has nothing to
143
222
  // ellipsise inside one.
223
+ //
224
+ // A `titleContent` is clamped by the same box as a plain one —
225
+ // it replaces the card's *words*, not the card's shape.
144
226
  <span className="line-clamp-2 cq-sm:line-clamp-none cq-sm:block cq-sm:truncate">
145
- {item.title}
227
+ {item.titleContent ?? item.title}
146
228
  </span>
147
229
  )
148
230
 
@@ -155,10 +237,36 @@ export const BoardCard = ({
155
237
  // cannot do this to itself — it is the container, and a
156
238
  // container query never matches its own container.
157
239
  "cq-lg:mb-2 cq-lg:rounded-lg cq-lg:border cq-lg:bg-surface-raised cq-lg:p-3 cq-lg:shadow-low",
240
+ // The edge is drawn on THIS box, which is why it needs no
241
+ // shape of its own: the box is a row below `cq-lg` and a
242
+ // rounded card above it, and `border-radius: inherit` gives
243
+ // the right answer at both — a straight stripe down a row
244
+ // that has no corner, and a bar that wraps the card's
245
+ // corner once it grows one.
246
+ //
247
+ // `ps-2` is what keeps the content off it. The bar is 3px
248
+ // and overlays rather than displaces, so without this the
249
+ // first mark sits on top of it; at `cq-lg` the card's own
250
+ // `p-3` is already wider than the bar.
251
+ item.accentEdge != null &&
252
+ toClassName(
253
+ "ps-2",
254
+ getAccentEdgeClassName(item.accentEdge),
255
+ ),
158
256
  item.isDimmed && "opacity-60",
159
257
  )}
160
258
  data-board-card={item.key}
259
+ {...(item.accentEdge == null
260
+ ? {}
261
+ : { style: getAccentEdgeStyle(item.accentEdge) })}
161
262
  >
263
+ {/* The label the bar cannot say. An edge has no content of
264
+ its own — it is a pseudo-element — so unlike the pill
265
+ arm below there is nowhere inside it to put the words. */}
266
+ {item.accentEdge != null && item.accentLabel ? (
267
+ <VisuallyHidden>{item.accentLabel}</VisuallyHidden>
268
+ ) : null}
269
+
162
270
  {item.accentIntent ? (
163
271
  <span
164
272
  className={toClassName(
@@ -191,7 +299,7 @@ export const BoardCard = ({
191
299
  href={item.href}
192
300
  intent="neutral"
193
301
  >
194
- {titleContent}
302
+ {titleBlock}
195
303
  </TextLink>
196
304
  ) : item.onSelect ? (
197
305
  <button
@@ -202,10 +310,10 @@ export const BoardCard = ({
202
310
  onClick={item.onSelect}
203
311
  type="button"
204
312
  >
205
- {titleContent}
313
+ {titleBlock}
206
314
  </button>
207
315
  ) : (
208
- titleContent
316
+ titleBlock
209
317
  )}
210
318
  </div>
211
319
 
@@ -69,7 +69,20 @@ export type MarkdownSpan = {
69
69
  text: string
70
70
  }
71
71
 
72
- export type MarkdownLineKind =
72
+ /**
73
+ * `MarkdownEditorLine`, not `MarkdownLine` — renamed in 3.18.0 when
74
+ * `MarkdownLine` became a **component**, and a barrel cannot export
75
+ * one name as both a type and a value.
76
+ *
77
+ * The editor's name gave way rather than the component's for two
78
+ * reasons. It is the lower-level of the two — the painted layer's
79
+ * view of one line of a *document*, which is a thing only
80
+ * `MarkdownEditor` has ever needed — and it had no consumer outside
81
+ * this package in any repo in the fleet, checked before the rename.
82
+ * A component that renders a title is the name a call site reaches
83
+ * for; a line-kind union is not.
84
+ */
85
+ export type MarkdownEditorLineKind =
73
86
  | "blockquote"
74
87
  | "code"
75
88
  | "heading"
@@ -78,8 +91,8 @@ export type MarkdownLineKind =
78
91
  | "table"
79
92
  | "thematicBreak"
80
93
 
81
- export type MarkdownLine = {
82
- kind: MarkdownLineKind
94
+ export type MarkdownEditorLine = {
95
+ kind: MarkdownEditorLineKind
83
96
  spans: MarkdownSpan[]
84
97
  }
85
98
 
@@ -352,7 +365,7 @@ const toTableSpans = (text: string): MarkdownSpan[] => {
352
365
  return spans
353
366
  }
354
367
 
355
- const toBlockLine = (line: string): MarkdownLine => {
368
+ const toBlockLine = (line: string): MarkdownEditorLine => {
356
369
  const spans: MarkdownSpan[] = []
357
370
 
358
371
  const indent = INDENT_PATTERN.exec(line)?.[0] ?? ""
@@ -440,7 +453,7 @@ const toBlockLine = (line: string): MarkdownLine => {
440
453
  */
441
454
  export const toMarkdownLines = (
442
455
  text: string,
443
- ): MarkdownLine[] => {
456
+ ): MarkdownEditorLine[] => {
444
457
  let isInsideFence = false
445
458
 
446
459
  return text.split("\n").map((line) => {
@@ -0,0 +1,135 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as MarkdownLineStories from "./MarkdownLine.stories";
4
+
5
+ <Meta of={MarkdownLineStories} />
6
+
7
+ # MarkdownLine
8
+
9
+ **One line of markdown, drawn with its inline marks and nothing else.** Reach for it
10
+ wherever a stored string is a *name* rather than a document — a card title, a lane heading,
11
+ a table cell, a breadcrumb.
12
+
13
+ <Canvas of={MarkdownLineStories.Default} />
14
+
15
+ ## Props
16
+
17
+ <Controls />
18
+
19
+ ## Why marks belong in a title
20
+
21
+ A grid of task names set in one flat weight is a wall. The parts that tell two cards apart —
22
+ the path, the urgency, the thing that is already done — are exactly the parts markdown would
23
+ have marked, so the marks are doing the same job the priority bar and the project colour do
24
+ one layer up: making a list **scannable**.
25
+
26
+ <Canvas of={MarkdownLineStories.WhyItHelps} />
27
+
28
+ ## It is inline-only, and it is one line
29
+
30
+ | Rendered | Literal text |
31
+ | --- | --- |
32
+ | `**bold**`, `__bold__` | `# heading` |
33
+ | `*italic*`, `_italic_` | `- list item`, `1. list item` |
34
+ | `` `code` `` | `> blockquote` |
35
+ | `~~strikethrough~~` | `\| table \| row \|` |
36
+ | `[text](url)`, `<url>`, bare `https://…` and `www.…` | `![image](url)` |
37
+
38
+ A newline collapses to a space. Every surface that draws one of these clamps to one or two
39
+ lines anyway, so a hard break would only push the second half of a title out of a box that
40
+ was measured for one line.
41
+
42
+ ## File names are not markup
43
+
44
+ The case this component is most often asked to get wrong.
45
+
46
+ <Canvas of={MarkdownLineStories.FileNames} />
47
+
48
+ `ingest_the_files.sh` keeps its underscores because CommonMark's flanking rules say an
49
+ underscore **inside a word** opens nothing — the rule that makes `_` and `*` genuinely
50
+ different characters, and the one a hand-rolled "strip the markdown" regex always misses.
51
+ `2 * 3 * 4` is arithmetic for the same reason: a delimiter followed by a space cannot open.
52
+
53
+ ## `href` — a link inside a link
54
+
55
+ <Canvas of={MarkdownLineStories.AsALink} />
56
+
57
+ A card title is usually a link to the card, and a title may also *contain* a link. An anchor
58
+ inside an anchor is invalid HTML: the parser closes the outer one at the inner one's start
59
+ tag, so the text after the inner link silently stops opening the card. It looks completely
60
+ correct and half of it does not work.
61
+
62
+ So `href` is a prop rather than something you wrap the component in. Given one, the plain
63
+ runs become anchors to `href` and a markdown link stays a **sibling** anchor with its own
64
+ destination:
65
+
66
+ ```tsx
67
+ <MarkdownLine href={`/tasks/${task.id}`} value={task.title} />
68
+ ```
69
+
70
+ Consecutive runs sharing a destination are merged into one anchor, so `**Ship** it` is a
71
+ single tab stop and one entry in a screen reader's link list.
72
+
73
+ A markdown link **splits** the line into more than one anchor, and each is named by the words
74
+ it covers — `Ship`, `#53`, `tonight`. That is honest, and it is not good: `tonight` is a poor
75
+ name for a link to a task. Naming all three after the whole line was tried and is worse — two
76
+ links with the same name are ambiguous to anything driving the page by name, and this
77
+ library's own `expectAgentDrivable` refuses it. The conclusion is a design one rather than an
78
+ API one: **a link usually belongs in a task's description**, where it has a sentence around
79
+ it, rather than in its title.
80
+
81
+ <Canvas of={MarkdownLineStories.Routed} />
82
+
83
+ An in-app destination goes through the injected `RouterLinkProvider`; an off-origin one
84
+ does not, and opens in a new tab. A title that names a pull request is a reference, and
85
+ following it should not lose the board the reader was on.
86
+
87
+ ## `isInsideLink` — when the card is already the link
88
+
89
+ <Canvas of={MarkdownLineStories.InsideACardLink} />
90
+
91
+ The third and last answer to *who owns the link here*. `href` says this component does;
92
+ neither says the markdown links do; `isInsideLink` says **nobody** — draw the marks and emit
93
+ no anchor at all.
94
+
95
+ Reach for it whenever the line sits inside a link the caller already owns, which is what a
96
+ whole-card link is. The trap it closes has teeth even when no author ever typed a link: a
97
+ **bare URL autolinks**, so a captured note whose title ends in `https://…` would nest an
98
+ anchor without anybody writing `[]()`.
99
+
100
+ ## Anywhere a string is required
101
+
102
+ `aria-label`, `title`, `document.title`, a confirmation sentence — call `toPlainMarkdownText`
103
+ rather than writing a regex that strips the markup:
104
+
105
+ ```ts
106
+ import { toPlainMarkdownText } from "@charcuterie/ui"
107
+
108
+ document.title = `${toPlainMarkdownText(task.title)} — Docket`
109
+ ```
110
+
111
+ It is the **same parse** the component renders from, so it cannot drift. A second stripper is
112
+ how a card comes to read `Ingest 53 movies` while its own tooltip reads
113
+ ``Ingest 53 movies from `Downloads/MOVIES` ``.
114
+
115
+ ## A refused URL keeps its source
116
+
117
+ <Canvas of={MarkdownLineStories.RefusedUrl} />
118
+
119
+ URLs cross `safeUrls.ts` — an allowlist of `http`, `https`, `mailto` and `tel` — before they
120
+ reach an `href`, the same module `MarkdownView` and the CodeMirror editor use. A scheme that
121
+ does not survive it produces **no anchor at all**, and every character of the source stays on
122
+ screen. A reader who can see `[click me](javascript:…)` is better served than one shown a
123
+ confident blue word that silently refuses to work.
124
+
125
+ ## Not a small `MarkdownView`
126
+
127
+ `MarkdownView` renders a *document*, through CodeMirror, from the optional
128
+ `@charcuterie/ui/markdown-editor-codemirror` entry point. Neither half suits a name: an
129
+ editor state, a syntax tree and a decoration set per card is an absurd price for forty
130
+ characters, and the main entry may not reach the CodeMirror stack at all.
131
+
132
+ So this is a second parser, which is worth being nervous about — two parsers are two answers
133
+ to "what does this markdown mean". Three things keep them honest: this one is **inline-only**,
134
+ so there is no block level to disagree about; both call the **same** URL guard; and the
135
+ flanking rules here are CommonMark's rather than a convenient approximation.