@charcuterie/ui 4.9.0 → 4.10.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.
@@ -0,0 +1,179 @@
1
+ import type { Key, ReactNode } from "react"
2
+
3
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
4
+ import { TextLink } from "../TextLink/TextLink.tsx"
5
+ import { toClassName } from "../toClassName.ts"
6
+
7
+ /**
8
+ * One rung of the trail.
9
+ *
10
+ * Which of the three it becomes is read off the fields it carries, so
11
+ * a caller mapping a path to items never has to say what the rung
12
+ * *is* — only where it goes:
13
+ *
14
+ * - `href` — a real `<a href>`, through `TextLink`, so an in-app path
15
+ * goes through the injected router exactly as every other link in
16
+ * the library does.
17
+ * - `onSelect` and no `href` — a `<button>` painted as the same link.
18
+ * For a trail over state that has no URL: image-viewer's folder
19
+ * path and mux-magic's file-explorer modal both move a browser
20
+ * around a disk without touching the address bar.
21
+ * - neither — plain text. On the **last** item that is the current
22
+ * page, and it carries `aria-current="page"`.
23
+ */
24
+ export type BreadcrumbItem = {
25
+ href?: string
26
+ /**
27
+ * React's list key. Optional, because `label` is a node and cannot
28
+ * be one; the fallback is `href`, then the position. Pass it when
29
+ * two rungs can share an `href` or the trail reorders.
30
+ */
31
+ key?: Key
32
+ label: ReactNode
33
+ /**
34
+ * Runs on activation. With an `href` it runs alongside the
35
+ * navigation (closing a drawer, say); without one it IS the
36
+ * navigation.
37
+ */
38
+ onSelect?: () => void
39
+ }
40
+
41
+ export type BreadcrumbsProps = {
42
+ className?: string
43
+ items: readonly BreadcrumbItem[]
44
+ /**
45
+ * The `<nav>` landmark's name. Two trails on one page need two
46
+ * different names, or axe's `landmark-unique` fails.
47
+ */
48
+ label?: string
49
+ /**
50
+ * Drawn between rungs, always `aria-hidden`. The default is a
51
+ * chevron drawn in CSS borders rather than a `›` character: the
52
+ * library ships no symbol glyphs in a default, because a font that
53
+ * lacks one paints nothing
54
+ * (`docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md`).
55
+ * Folio's `/` is a fine override — it is the app's choice to make.
56
+ */
57
+ separator?: ReactNode
58
+ }
59
+
60
+ /**
61
+ * The link paint without the `<a>`: `TextLink`'s standalone look,
62
+ * restated for the `onSelect` rung so a button crumb and a link crumb
63
+ * are indistinguishable at rest, on hover, and under focus.
64
+ */
65
+ const BUTTON_CRUMB_CLASS =
66
+ "inline cursor-pointer rounded-xs border-0 bg-transparent p-0 text-start font-medium text-intent-accent-content underline-offset-2 hover:underline"
67
+
68
+ /**
69
+ * `wrap-anywhere` on every label, and no `truncate`: a trail that cuts
70
+ * a folder name to `Office Sh…` hides the one word the reader came to
71
+ * find. A long unbroken segment — a hash, a file name — breaks inside
72
+ * itself instead of pushing the row wider than its container.
73
+ */
74
+ const LABEL_CLASS = "min-w-0 wrap-anywhere"
75
+
76
+ /**
77
+ * Right-pointing in a left-to-right page, left-pointing in a
78
+ * right-to-left one: the two borders are logical (`border-e`), and the
79
+ * rotation flips with the direction so the corner always points the
80
+ * way the trail reads.
81
+ */
82
+ const DEFAULT_SEPARATOR = (
83
+ <span className="inline-block size-1.5 shrink-0 rotate-45 border-e-[1.5px] border-t-[1.5px] border-current rtl:-rotate-45" />
84
+ )
85
+
86
+ const getItemKey = (
87
+ item: BreadcrumbItem,
88
+ index: number,
89
+ ): Key => item.key ?? item.href ?? index
90
+
91
+ /**
92
+ * A path back up a hierarchy: `Places › Office › Office Shelves`.
93
+ *
94
+ * Folio, image-viewer and mux-magic each hand-rolled one, and they
95
+ * disagreed on everything a shared shape should settle — one was a
96
+ * `<nav>` of bare links with no list, two were `<div>`s of buttons
97
+ * with no landmark, one truncated the current segment, one gave the
98
+ * current folder no `aria-current`. This is the WAI-ARIA APG
99
+ * breadcrumb pattern: a `<nav>` landmark around an `<ol>`, because
100
+ * the order is the meaning.
101
+ *
102
+ * The current page is the **last item with nowhere to go**. A trail
103
+ * that sits above a heading already naming the page — Whereabouts'
104
+ * `Places › Office` over an `Office Shelves` heading — gives every
105
+ * item an `href`, and then no rung is current, which is correct: the
106
+ * page is named once, by the heading.
107
+ */
108
+ export const Breadcrumbs = ({
109
+ className,
110
+ items,
111
+ label = "Breadcrumb",
112
+ separator,
113
+ }: BreadcrumbsProps): ReactNode => (
114
+ <nav aria-label={label} className={className}>
115
+ <ol className="m-0 flex list-none flex-wrap items-center gap-x-2 gap-y-1 p-0 text-content-secondary text-sm">
116
+ {items.map((item, index) => {
117
+ const isLast = index === items.length - 1
118
+
119
+ const isCurrent =
120
+ isLast &&
121
+ item.href === undefined &&
122
+ item.onSelect === undefined
123
+
124
+ return (
125
+ <li
126
+ className="inline-flex min-w-0 items-center gap-x-2"
127
+ key={getItemKey(item, index)}
128
+ >
129
+ {index > 0 ? (
130
+ <span
131
+ aria-hidden="true"
132
+ className="inline-flex shrink-0 items-center text-content-muted"
133
+ >
134
+ {separator ?? DEFAULT_SEPARATOR}
135
+ </span>
136
+ ) : null}
137
+
138
+ {item.href !== undefined ? (
139
+ <TextLink
140
+ appearance="standalone"
141
+ className={LABEL_CLASS}
142
+ href={item.href}
143
+ onClick={item.onSelect}
144
+ >
145
+ {item.label}
146
+ </TextLink>
147
+ ) : item.onSelect !== undefined ? (
148
+ <button
149
+ className={toClassName(
150
+ BUTTON_CRUMB_CLASS,
151
+ FOCUS_RING_CLASS,
152
+ LABEL_CLASS,
153
+ )}
154
+ onClick={item.onSelect}
155
+ type="button"
156
+ >
157
+ {item.label}
158
+ </button>
159
+ ) : (
160
+ <span
161
+ aria-current={
162
+ isCurrent ? "page" : undefined
163
+ }
164
+ className={toClassName(
165
+ LABEL_CLASS,
166
+ isCurrent
167
+ ? "font-medium text-content-primary"
168
+ : undefined,
169
+ )}
170
+ >
171
+ {item.label}
172
+ </span>
173
+ )}
174
+ </li>
175
+ )
176
+ })}
177
+ </ol>
178
+ </nav>
179
+ )
package/src/index.ts CHANGED
@@ -37,6 +37,8 @@ export type {
37
37
  AccordionProps,
38
38
  } from "./Accordion/Accordion.tsx"
39
39
  export { Accordion } from "./Accordion/Accordion.tsx"
40
+ export type { ActionMenuProps } from "./ActionMenu/ActionMenu.tsx"
41
+ export { ActionMenu } from "./ActionMenu/ActionMenu.tsx"
40
42
  export type {
41
43
  ActionTileItem,
42
44
  ActionTilesAccent,
@@ -99,6 +101,11 @@ export {
99
101
  getIsMoveMeaningful,
100
102
  toSettledIndex,
101
103
  } from "./Board/boardMove.ts"
104
+ export type {
105
+ BreadcrumbItem,
106
+ BreadcrumbsProps,
107
+ } from "./Breadcrumbs/Breadcrumbs.tsx"
108
+ export { Breadcrumbs } from "./Breadcrumbs/Breadcrumbs.tsx"
102
109
  export type { ButtonProps } from "./Button/Button.tsx"
103
110
  export { Button } from "./Button/Button.tsx"
104
111
  export type { ButtonLinkProps } from "./ButtonLink/ButtonLink.tsx"
@@ -0,0 +1,88 @@
1
+ import {
2
+ markdown,
3
+ markdownLanguage,
4
+ } from "@codemirror/lang-markdown"
5
+ import { Language, syntaxTree } from "@codemirror/language"
6
+ import {
7
+ EditorState,
8
+ type Transaction,
9
+ } from "@codemirror/state"
10
+ import { expect, test } from "vitest"
11
+
12
+ import {
13
+ isInlineDecorationStale,
14
+ livePreview,
15
+ } from "./livePreview.ts"
16
+
17
+ const toUpdate = (transaction: Transaction) => ({
18
+ docChanged: transaction.docChanged,
19
+ selectionSet: transaction.selection !== undefined,
20
+ viewportChanged: false,
21
+ focusChanged: false,
22
+ transactions: [transaction],
23
+ startState: transaction.startState,
24
+ state: transaction.state,
25
+ })
26
+
27
+ /**
28
+ * A parse step with nothing else in it, the way CodeMirror's own
29
+ * background worker delivers one: `Language.setState` carrying a new
30
+ * `LanguageState` over the same, further-advanced parse context.
31
+ *
32
+ * Both are internal to `@codemirror/language`, which is why this is
33
+ * spelled out rather than hidden in a helper. The alternative — a
34
+ * real `EditorView` racing a real idle callback — is what made the
35
+ * VRT story flake, and a test that only fails when the runner is
36
+ * slow is the same flake with a different name.
37
+ */
38
+ const parseFurther = (state: EditorState) => {
39
+ const internals = Language as unknown as {
40
+ setState: { of: (value: unknown) => never }
41
+ state: Parameters<EditorState["field"]>[0]
42
+ }
43
+ const current = state.field(internals.state) as {
44
+ constructor: new (context: unknown) => unknown
45
+ context: {
46
+ work: (time: number, upto: number) => boolean
47
+ }
48
+ }
49
+ current.context.work(5000, state.doc.length)
50
+
51
+ return state.update({
52
+ effects: internals.setState.of(
53
+ new current.constructor(current.context),
54
+ ),
55
+ })
56
+ }
57
+
58
+ test("a parse step alone makes the inline decorations stale", () => {
59
+ const doc =
60
+ "Read [the runbook](https://example.invalid) first.\n\n".repeat(
61
+ 3000,
62
+ )
63
+ const state = EditorState.create({
64
+ doc,
65
+ extensions: [
66
+ markdown({ base: markdownLanguage }),
67
+ livePreview(),
68
+ ],
69
+ })
70
+
71
+ // The precondition the whole test rests on: the first parse stops
72
+ // short of the end, as it does on any long document.
73
+ expect(syntaxTree(state).length).toBeLessThan(doc.length)
74
+
75
+ const idle = state.update({})
76
+
77
+ expect(isInlineDecorationStale(toUpdate(idle))).toBe(
78
+ false,
79
+ )
80
+
81
+ const parsed = parseFurther(state)
82
+
83
+ expect(parsed.docChanged).toBe(false)
84
+ expect(syntaxTree(parsed.state).length).toBe(doc.length)
85
+ expect(isInlineDecorationStale(toUpdate(parsed))).toBe(
86
+ true,
87
+ )
88
+ })
@@ -957,6 +957,50 @@ const toSafeDecorations = (
957
957
  }
958
958
  }
959
959
 
960
+ /**
961
+ * Whether the inline decorations are stale after `update`.
962
+ *
963
+ * Exported for its test: the tree clause was missing here for months
964
+ * while `livePreviewTableField` had it. Without it, a document whose
965
+ * background parse finished after mount painted its last lines raw —
966
+ * a fence and a link as plain text — until the reader clicked, and a
967
+ * busy VRT runner photographed exactly that.
968
+ */
969
+ export const isInlineDecorationStale = (
970
+ update: Pick<
971
+ ViewUpdate,
972
+ | "docChanged"
973
+ | "selectionSet"
974
+ | "viewportChanged"
975
+ | "focusChanged"
976
+ | "transactions"
977
+ | "startState"
978
+ | "state"
979
+ >,
980
+ ) =>
981
+ // Selection is in the list because concealment depends on the
982
+ // caret: moving it with an arrow key changes what is painted
983
+ // even though the document did not change.
984
+ update.docChanged ||
985
+ update.selectionSet ||
986
+ update.viewportChanged ||
987
+ // Focus is a decoration input now — see `toDecorations`.
988
+ update.focusChanged ||
989
+ update.transactions.some((transaction) =>
990
+ transaction.effects.some((effect) =>
991
+ effect.is(setLivePreviewRawMode),
992
+ ),
993
+ ) ||
994
+ // The same `Compartment` reconfigure the block pass watches
995
+ // for: a checkbox that just became operable is a widget
996
+ // that has to be rebuilt.
997
+ update.startState.facet(livePreviewOptions) !==
998
+ update.state.facet(livePreviewOptions) ||
999
+ // Parsing is incremental and time-sliced — the same reason the
1000
+ // table field compares the tree. A parse step arrives as a
1001
+ // transaction that changes nothing else.
1002
+ syntaxTree(update.state) !== syntaxTree(update.startState)
1003
+
960
1004
  const livePreviewPlugin = ViewPlugin.fromClass(
961
1005
  class {
962
1006
  decorations: DecorationSet
@@ -966,26 +1010,7 @@ const livePreviewPlugin = ViewPlugin.fromClass(
966
1010
  }
967
1011
 
968
1012
  update(update: ViewUpdate) {
969
- // Selection is in the list because concealment depends on the
970
- // caret: moving it with an arrow key changes what is painted
971
- // even though the document did not change.
972
- if (
973
- update.docChanged ||
974
- update.selectionSet ||
975
- update.viewportChanged ||
976
- // Focus is a decoration input now — see `toDecorations`.
977
- update.focusChanged ||
978
- update.transactions.some((transaction) =>
979
- transaction.effects.some((effect) =>
980
- effect.is(setLivePreviewRawMode),
981
- ),
982
- ) ||
983
- // The same `Compartment` reconfigure the block pass watches
984
- // for: a checkbox that just became operable is a widget
985
- // that has to be rebuilt.
986
- update.startState.facet(livePreviewOptions) !==
987
- update.state.facet(livePreviewOptions)
988
- ) {
1013
+ if (isInlineDecorationStale(update)) {
989
1014
  this.decorations = toSafeDecorations(update.view)
990
1015
  }
991
1016
  }
@@ -615,7 +615,9 @@ test("the barrel is the only place components are re-exported", async () => {
615
615
  // question the same way the bars are placed, or the two answers
616
616
  // drift by a day and nothing says so.
617
617
  // ProgressCard composes Card and ProgressBar for progress-first dashboards.
618
- expect(componentNames.length).toBe(67)
618
+ // Breadcrumbs composes TextLink for the path back up a hierarchy.
619
+ // ActionMenu composes IconButton and Menu for the overflow control.
620
+ expect(componentNames.length).toBe(69)
619
621
 
620
622
  for (const name of componentNames) {
621
623
  expect(barrel).toContain(`export { ${name} }`)