@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.
- package/dist/ActionMenu/ActionMenu.d.ts +36 -0
- package/dist/ActionMenu/ActionMenu.d.ts.map +1 -0
- package/dist/ActionMenu/ActionMenu.js +40 -0
- package/dist/ActionMenu/ActionMenu.js.map +1 -0
- package/dist/Breadcrumbs/Breadcrumbs.d.ts +71 -0
- package/dist/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
- package/dist/Breadcrumbs/Breadcrumbs.js +52 -0
- package/dist/Breadcrumbs/Breadcrumbs.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +11 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/livePreview.js +29 -14
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
- package/package.json +1 -1
- package/src/ActionMenu/ActionMenu.mdx +51 -0
- package/src/ActionMenu/ActionMenu.stories.tsx +183 -0
- package/src/ActionMenu/ActionMenu.test.tsx +187 -0
- package/src/ActionMenu/ActionMenu.tsx +96 -0
- package/src/Breadcrumbs/Breadcrumbs.mdx +71 -0
- package/src/Breadcrumbs/Breadcrumbs.stories.tsx +319 -0
- package/src/Breadcrumbs/Breadcrumbs.test.tsx +250 -0
- package/src/Breadcrumbs/Breadcrumbs.tsx +179 -0
- package/src/index.ts +7 -0
- package/src/markdownEditorCodeMirror/livePreview.test.ts +88 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +45 -20
- package/src/sourceRules.test.ts +3 -1
|
@@ -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
|
-
|
|
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
|
}
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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} }`)
|