@tristan2828/ui-foundation 3.12.0 → 3.14.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/README.md +1 -1
- package/conventions/docs/add-an-entity.md +57 -12
- package/conventions/docs/cell-patterns.md +369 -17
- package/conventions/docs/design-language.md +23 -4
- package/conventions/docs/entity-plan-template.md +21 -9
- package/dist/components/app/edit-in-place-store.d.ts +76 -0
- package/dist/components/app/edit-in-place-store.js +159 -0
- package/dist/components/app/edit-in-place.d.ts +88 -0
- package/dist/components/app/edit-in-place.js +10 -0
- package/dist/components/app/editable-value.d.ts +23 -0
- package/dist/components/app/editable-value.js +176 -0
- package/dist/components/app/entity-view.d.ts +10 -1
- package/dist/components/app/entity-view.js +20 -7
- package/dist/components/app/milkdown-editor.d.ts +2 -0
- package/dist/components/app/milkdown-editor.js +221 -0
- package/dist/components/app/multi-reference.d.ts +5 -1
- package/dist/components/app/multi-reference.js +2 -2
- package/dist/components/app/rich-text-editor.d.ts +20 -0
- package/dist/components/app/rich-text-editor.js +25 -0
- package/dist/components/app/stage-circle.d.ts +20 -0
- package/dist/components/app/stage-circle.js +30 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/field.js +2 -2
- package/dist/components/ui/toggle.d.ts +8 -0
- package/dist/components/ui/toggle.js +33 -0
- package/dist/hooks/use-record-update.d.ts +14 -1
- package/dist/hooks/use-record-update.js +10 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +196 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class MarkdownMergeError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The Markdown to save: `original` with the edits that turned `baseline`
|
|
6
|
+
* (the editor's serialization as loaded) into `current` (its serialization
|
|
7
|
+
* now), every unchanged top-level block kept as `original` wrote it.
|
|
8
|
+
* Throws `MarkdownMergeError` rather than save something that means other
|
|
9
|
+
* than `current`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function mergeMarkdown(original: string, baseline: string, current: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The document's own marks, for writing a block the person edited the way
|
|
14
|
+
* the rest is written: its bullet (`-`, `*`, `+`) and its thematic break.
|
|
15
|
+
*/
|
|
16
|
+
export declare function markdownStyle(markdown: string): {
|
|
17
|
+
bullet: '-' | '*' | '+';
|
|
18
|
+
rule: '-' | '*' | '_';
|
|
19
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Saving rich-text edits back to Markdown without rewriting what the
|
|
2
|
+
// person didn't touch.
|
|
3
|
+
//
|
|
4
|
+
// The rich-text editor (RichTextEditor) loads Markdown into a document and
|
|
5
|
+
// serializes it back. Its serializer is faithful to meaning but has its own
|
|
6
|
+
// style: bullets, table padding, rules and hard breaks come back written
|
|
7
|
+
// its way, and a reference-style link comes back inline. Saving that
|
|
8
|
+
// whole would quietly rewrite every note anyone opened. So a save is
|
|
9
|
+
// assembled block by block:
|
|
10
|
+
//
|
|
11
|
+
// - `baseline` is the editor's serialization of the document as loaded,
|
|
12
|
+
// `current` its serialization now. Equal means nothing was edited: the
|
|
13
|
+
// original comes back as it was, byte for byte.
|
|
14
|
+
// - Otherwise each top-level block (a paragraph, a heading, a whole list or
|
|
15
|
+
// table) is matched by meaning: original ↔ baseline, then baseline ↔
|
|
16
|
+
// current. A block the person didn't change is written from the
|
|
17
|
+
// original's own source; only a changed or new block is written by the
|
|
18
|
+
// editor (in the document's own bullet and rule marks, `markdownStyle`).
|
|
19
|
+
// - Blocks the editor can't show (a link reference's definition) are kept
|
|
20
|
+
// where they were.
|
|
21
|
+
// - Guard: the result must mean exactly what the editor shows. If it
|
|
22
|
+
// wouldn't (two blocks fusing, say), the merge throws instead of saving
|
|
23
|
+
// something else; the draft stays on screen.
|
|
24
|
+
import remarkGfm from 'remark-gfm';
|
|
25
|
+
import remarkParse from 'remark-parse';
|
|
26
|
+
import { unified } from 'unified';
|
|
27
|
+
const processor = unified().use(remarkParse).use(remarkGfm);
|
|
28
|
+
function parse(markdown) {
|
|
29
|
+
return processor.runSync(processor.parse(markdown));
|
|
30
|
+
}
|
|
31
|
+
// Fields that are about how a node was written, not what it means.
|
|
32
|
+
const STYLE_ONLY = new Set(['position', 'data', 'spread', 'label', 'referenceType']);
|
|
33
|
+
// A block's meaning as a comparable string: positions and source style
|
|
34
|
+
// dropped, and a reference link resolved to the link it stands for.
|
|
35
|
+
function meaning(node, definitions) {
|
|
36
|
+
if (Array.isArray(node))
|
|
37
|
+
return node.map((child) => meaning(child, definitions));
|
|
38
|
+
if (!node || typeof node !== 'object')
|
|
39
|
+
return node;
|
|
40
|
+
const md = node;
|
|
41
|
+
if (md.type === 'linkReference' || md.type === 'imageReference') {
|
|
42
|
+
const definition = definitions.get(String(md.identifier));
|
|
43
|
+
if (definition) {
|
|
44
|
+
return meaning({
|
|
45
|
+
type: md.type === 'linkReference' ? 'link' : 'image',
|
|
46
|
+
url: definition.url,
|
|
47
|
+
title: definition.title ?? null,
|
|
48
|
+
alt: md.alt,
|
|
49
|
+
children: md.children,
|
|
50
|
+
}, definitions);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const key of Object.keys(md).sort()) {
|
|
55
|
+
if (STYLE_ONLY.has(key))
|
|
56
|
+
continue;
|
|
57
|
+
out[key] = meaning(md[key], definitions);
|
|
58
|
+
}
|
|
59
|
+
if ((md.type === 'link' || md.type === 'image') && out.title === undefined)
|
|
60
|
+
out.title = null;
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
function blocks(markdown) {
|
|
64
|
+
const tree = parse(markdown);
|
|
65
|
+
const children = tree.children ?? [];
|
|
66
|
+
const definitions = new Map();
|
|
67
|
+
for (const child of children)
|
|
68
|
+
if (child.type === 'definition')
|
|
69
|
+
definitions.set(String(child.identifier), child);
|
|
70
|
+
return children.map((child) => {
|
|
71
|
+
const start = child.position?.start.offset ?? 0;
|
|
72
|
+
const end = child.position?.end.offset ?? 0;
|
|
73
|
+
return {
|
|
74
|
+
type: child.type,
|
|
75
|
+
start,
|
|
76
|
+
end,
|
|
77
|
+
source: markdown.slice(start, end),
|
|
78
|
+
key: JSON.stringify(meaning(child, definitions)),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Longest common subsequence by meaning: which `to` block is which `from`
|
|
83
|
+
// block, as a map from `to` index to `from` index.
|
|
84
|
+
function match(from, to) {
|
|
85
|
+
const table = Array.from({ length: from.length + 1 }, () => new Array(to.length + 1).fill(0));
|
|
86
|
+
for (let i = from.length - 1; i >= 0; i--) {
|
|
87
|
+
for (let j = to.length - 1; j >= 0; j--) {
|
|
88
|
+
table[i][j] = from[i].key === to[j].key ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const pairs = new Map();
|
|
92
|
+
let i = 0;
|
|
93
|
+
let j = 0;
|
|
94
|
+
while (i < from.length && j < to.length) {
|
|
95
|
+
if (from[i].key === to[j].key) {
|
|
96
|
+
pairs.set(j, i);
|
|
97
|
+
i++;
|
|
98
|
+
j++;
|
|
99
|
+
}
|
|
100
|
+
else if (table[i + 1][j] >= table[i][j + 1])
|
|
101
|
+
i++;
|
|
102
|
+
else
|
|
103
|
+
j++;
|
|
104
|
+
}
|
|
105
|
+
return pairs;
|
|
106
|
+
}
|
|
107
|
+
export class MarkdownMergeError extends Error {
|
|
108
|
+
constructor() {
|
|
109
|
+
super("This text can't be saved without changing parts you didn't edit. Undo the last change, or edit it in the form.");
|
|
110
|
+
this.name = 'MarkdownMergeError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The Markdown to save: `original` with the edits that turned `baseline`
|
|
115
|
+
* (the editor's serialization as loaded) into `current` (its serialization
|
|
116
|
+
* now), every unchanged top-level block kept as `original` wrote it.
|
|
117
|
+
* Throws `MarkdownMergeError` rather than save something that means other
|
|
118
|
+
* than `current`.
|
|
119
|
+
*/
|
|
120
|
+
export function mergeMarkdown(original, baseline, current) {
|
|
121
|
+
if (current === baseline)
|
|
122
|
+
return original;
|
|
123
|
+
const originals = blocks(original);
|
|
124
|
+
const loaded = blocks(baseline);
|
|
125
|
+
const edited = blocks(current);
|
|
126
|
+
const loadedToOriginal = match(originals, loaded);
|
|
127
|
+
const editedToLoaded = match(loaded, edited);
|
|
128
|
+
// Each edited block's original, if it is one unchanged.
|
|
129
|
+
const sources = edited.map((_block, index) => {
|
|
130
|
+
const loadedIndex = editedToLoaded.get(index);
|
|
131
|
+
return loadedIndex === undefined ? undefined : loadedToOriginal.get(loadedIndex);
|
|
132
|
+
});
|
|
133
|
+
// A written block sits between the originals around it.
|
|
134
|
+
const items = edited.map((block, index) => {
|
|
135
|
+
const source = sources[index];
|
|
136
|
+
if (source !== undefined)
|
|
137
|
+
return { original: source, at: source };
|
|
138
|
+
const before = sources.slice(0, index).reverse().find((value) => value !== undefined) ?? -1;
|
|
139
|
+
const after = sources.slice(index + 1).find((value) => value !== undefined) ?? originals.length;
|
|
140
|
+
return { written: block, at: (before + after) / 2 };
|
|
141
|
+
});
|
|
142
|
+
// Blocks the editor never showed (definitions): after whatever came
|
|
143
|
+
// before them in the original.
|
|
144
|
+
const shown = new Set(loadedToOriginal.values());
|
|
145
|
+
originals.forEach((_block, index) => {
|
|
146
|
+
if (shown.has(index))
|
|
147
|
+
return;
|
|
148
|
+
let position = 0;
|
|
149
|
+
for (let k = items.length - 1; k >= 0; k--) {
|
|
150
|
+
if (items[k].at < index) {
|
|
151
|
+
position = k + 1;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
items.splice(position, 0, { original: index, at: index });
|
|
156
|
+
});
|
|
157
|
+
let out = '';
|
|
158
|
+
items.forEach((item, k) => {
|
|
159
|
+
const previous = items[k - 1];
|
|
160
|
+
if (!previous) {
|
|
161
|
+
if ('original' in item && item.original === 0)
|
|
162
|
+
out += original.slice(0, originals[0].start);
|
|
163
|
+
}
|
|
164
|
+
else if ('original' in previous && 'original' in item && item.original === previous.original + 1) {
|
|
165
|
+
out += original.slice(originals[previous.original].end, originals[item.original].start);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
out += '\n\n';
|
|
169
|
+
}
|
|
170
|
+
out += 'original' in item ? originals[item.original].source : item.written.source;
|
|
171
|
+
});
|
|
172
|
+
// The document ends the way the original ended, whichever block is last
|
|
173
|
+
// now: an edit to the last block adds no newline the person didn't type.
|
|
174
|
+
// Text written from nothing ends the way the editor ends it.
|
|
175
|
+
const ending = originals.length > 0
|
|
176
|
+
? original.slice(originals[originals.length - 1].end)
|
|
177
|
+
: current.slice(edited.at(-1)?.end ?? current.length);
|
|
178
|
+
if (items.length > 0)
|
|
179
|
+
out += ending;
|
|
180
|
+
const saved = blocks(out)
|
|
181
|
+
.filter((block) => block.type !== 'definition')
|
|
182
|
+
.map((block) => block.key);
|
|
183
|
+
const shownNow = edited.filter((block) => block.type !== 'definition').map((block) => block.key);
|
|
184
|
+
if (JSON.stringify(saved) !== JSON.stringify(shownNow))
|
|
185
|
+
throw new MarkdownMergeError();
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The document's own marks, for writing a block the person edited the way
|
|
190
|
+
* the rest is written: its bullet (`-`, `*`, `+`) and its thematic break.
|
|
191
|
+
*/
|
|
192
|
+
export function markdownStyle(markdown) {
|
|
193
|
+
const bullet = /^\s*([-*+])\s/m.exec(markdown)?.[1];
|
|
194
|
+
const rule = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/m.exec(markdown)?.[1];
|
|
195
|
+
return { bullet: bullet ?? '-', rule: rule ?? '-' };
|
|
196
|
+
}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -11,7 +11,24 @@ export declare function forceMswOverride(page: Page, override: MswOverride): Pro
|
|
|
11
11
|
export declare function waitForMswReady(page: Page): Promise<void>;
|
|
12
12
|
/** Starts the page logged out: GET /auth/me answers 401 on first load. */
|
|
13
13
|
export declare function forceLoggedOut(page: Page): Promise<void>;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Leaves an open popup's own scaffolding out of an axe run: Base UI's
|
|
16
|
+
* focus guards, invisible `aria-hidden` sentinels that hand focus back
|
|
17
|
+
* when you tab past a non-modal list. Hidden yet focusable is their job;
|
|
18
|
+
* they're never read out.
|
|
19
|
+
*/
|
|
20
|
+
export declare const POPUP_FOCUS_GUARDS = "[data-base-ui-focus-guard]";
|
|
21
|
+
/**
|
|
22
|
+
* axe on the page as it is now. The options are for a state whose
|
|
23
|
+
* structure a rule doesn't fit, said where they're used: a list open in a
|
|
24
|
+
* portal sits outside the page's landmarks by design (`disableRules:
|
|
25
|
+
* ['region']`) and brings focus guards (`exclude: [POPUP_FOCUS_GUARDS]`),
|
|
26
|
+
* while every other rule, contrast included, still checks the list.
|
|
27
|
+
*/
|
|
28
|
+
export declare function expectNoAxeViolations(page: Page, options?: {
|
|
29
|
+
disableRules?: string[];
|
|
30
|
+
exclude?: string[];
|
|
31
|
+
}): Promise<void>;
|
|
15
32
|
export type A11ySuiteOptions = {
|
|
16
33
|
/**
|
|
17
34
|
* Form screens, which aren't in the sidebar: each entity's create route,
|
package/dist/testing/index.js
CHANGED
|
@@ -36,8 +36,27 @@ export function forceLoggedOut(page) {
|
|
|
36
36
|
body: { detail: 'Not authenticated' },
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Leaves an open popup's own scaffolding out of an axe run: Base UI's
|
|
41
|
+
* focus guards, invisible `aria-hidden` sentinels that hand focus back
|
|
42
|
+
* when you tab past a non-modal list. Hidden yet focusable is their job;
|
|
43
|
+
* they're never read out.
|
|
44
|
+
*/
|
|
45
|
+
export const POPUP_FOCUS_GUARDS = '[data-base-ui-focus-guard]';
|
|
46
|
+
/**
|
|
47
|
+
* axe on the page as it is now. The options are for a state whose
|
|
48
|
+
* structure a rule doesn't fit, said where they're used: a list open in a
|
|
49
|
+
* portal sits outside the page's landmarks by design (`disableRules:
|
|
50
|
+
* ['region']`) and brings focus guards (`exclude: [POPUP_FOCUS_GUARDS]`),
|
|
51
|
+
* while every other rule, contrast included, still checks the list.
|
|
52
|
+
*/
|
|
53
|
+
export async function expectNoAxeViolations(page, options = {}) {
|
|
54
|
+
const builder = new AxeBuilder({ page });
|
|
55
|
+
if (options.disableRules?.length)
|
|
56
|
+
builder.disableRules(options.disableRules);
|
|
57
|
+
for (const selector of options.exclude ?? [])
|
|
58
|
+
builder.exclude(selector);
|
|
59
|
+
const results = await builder.analyze();
|
|
41
60
|
expect(results.violations).toEqual([]);
|
|
42
61
|
}
|
|
43
62
|
// The app's own pages, read from the Primary nav. Below the sidebar's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -128,6 +128,7 @@
|
|
|
128
128
|
"dependencies": {
|
|
129
129
|
"@fontsource-variable/geist": "^5.3.0",
|
|
130
130
|
"@hookform/resolvers": "^5.9.1",
|
|
131
|
+
"@milkdown/kit": "^7.22.2",
|
|
131
132
|
"class-variance-authority": "^0.7.1",
|
|
132
133
|
"cn": "^0.3.0",
|
|
133
134
|
"date-fns": "^4.4.0",
|
|
@@ -136,8 +137,10 @@
|
|
|
136
137
|
"react-day-picker": "^10.0.1",
|
|
137
138
|
"react-markdown": "^10.1.0",
|
|
138
139
|
"remark-gfm": "^4.0.1",
|
|
140
|
+
"remark-parse": "^11.0.0",
|
|
139
141
|
"shadcn": "^4.21.0",
|
|
140
|
-
"tw-animate-css": "^1.4.0"
|
|
142
|
+
"tw-animate-css": "^1.4.0",
|
|
143
|
+
"unified": "^11.0.5"
|
|
141
144
|
},
|
|
142
145
|
"devDependencies": {
|
|
143
146
|
"@axe-core/playwright": "^4.13.0",
|