@furystack/shades-common-components 12.1.0 → 12.3.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/CHANGELOG.md +42 -0
- package/esm/components/avatar.d.ts.map +1 -1
- package/esm/components/avatar.js +3 -1
- package/esm/components/avatar.js.map +1 -1
- package/esm/components/avatar.spec.js +4 -4
- package/esm/components/avatar.spec.js.map +1 -1
- package/esm/components/icons/icon-definitions.d.ts +82 -0
- package/esm/components/icons/icon-definitions.d.ts.map +1 -1
- package/esm/components/icons/icon-definitions.js +717 -0
- package/esm/components/icons/icon-definitions.js.map +1 -1
- package/esm/components/icons/icon-definitions.spec.js +22 -2
- package/esm/components/icons/icon-definitions.spec.js.map +1 -1
- package/esm/components/icons/icon-types.d.ts +10 -0
- package/esm/components/icons/icon-types.d.ts.map +1 -1
- package/esm/components/icons/index.d.ts +1 -1
- package/esm/components/icons/index.d.ts.map +1 -1
- package/esm/components/index.d.ts +1 -0
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +1 -0
- package/esm/components/index.js.map +1 -1
- package/esm/components/markdown/index.d.ts +5 -0
- package/esm/components/markdown/index.d.ts.map +1 -0
- package/esm/components/markdown/index.js +5 -0
- package/esm/components/markdown/index.js.map +1 -0
- package/esm/components/markdown/markdown-display.d.ts +19 -0
- package/esm/components/markdown/markdown-display.d.ts.map +1 -0
- package/esm/components/markdown/markdown-display.js +149 -0
- package/esm/components/markdown/markdown-display.js.map +1 -0
- package/esm/components/markdown/markdown-display.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-display.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-display.spec.js +191 -0
- package/esm/components/markdown/markdown-display.spec.js.map +1 -0
- package/esm/components/markdown/markdown-editor.d.ts +25 -0
- package/esm/components/markdown/markdown-editor.d.ts.map +1 -0
- package/esm/components/markdown/markdown-editor.js +113 -0
- package/esm/components/markdown/markdown-editor.js.map +1 -0
- package/esm/components/markdown/markdown-editor.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-editor.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-editor.spec.js +111 -0
- package/esm/components/markdown/markdown-editor.spec.js.map +1 -0
- package/esm/components/markdown/markdown-input.d.ts +29 -0
- package/esm/components/markdown/markdown-input.d.ts.map +1 -0
- package/esm/components/markdown/markdown-input.js +100 -0
- package/esm/components/markdown/markdown-input.js.map +1 -0
- package/esm/components/markdown/markdown-input.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-input.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-input.spec.js +215 -0
- package/esm/components/markdown/markdown-input.spec.js.map +1 -0
- package/esm/components/markdown/markdown-parser.d.ts +82 -0
- package/esm/components/markdown/markdown-parser.d.ts.map +1 -0
- package/esm/components/markdown/markdown-parser.js +274 -0
- package/esm/components/markdown/markdown-parser.js.map +1 -0
- package/esm/components/markdown/markdown-parser.spec.d.ts +2 -0
- package/esm/components/markdown/markdown-parser.spec.d.ts.map +1 -0
- package/esm/components/markdown/markdown-parser.spec.js +229 -0
- package/esm/components/markdown/markdown-parser.spec.js.map +1 -0
- package/esm/components/page-container/index.d.ts +1 -1
- package/esm/components/page-container/index.js +1 -1
- package/esm/components/page-container/page-header.d.ts +5 -5
- package/esm/components/page-container/page-header.d.ts.map +1 -1
- package/esm/components/page-container/page-header.js +3 -3
- package/esm/components/styles.d.ts +1 -0
- package/esm/components/styles.d.ts.map +1 -1
- package/esm/components/styles.js.map +1 -1
- package/esm/components/suggest/index.d.ts +1 -1
- package/esm/components/suggest/index.d.ts.map +1 -1
- package/esm/components/typography.d.ts.map +1 -1
- package/esm/components/typography.js +26 -14
- package/esm/components/typography.js.map +1 -1
- package/esm/services/css-variable-theme.d.ts +3 -0
- package/esm/services/css-variable-theme.d.ts.map +1 -1
- package/esm/services/css-variable-theme.js +3 -0
- package/esm/services/css-variable-theme.js.map +1 -1
- package/esm/services/css-variable-theme.spec.js +3 -0
- package/esm/services/css-variable-theme.spec.js.map +1 -1
- package/esm/services/default-dark-palette.d.ts +8 -0
- package/esm/services/default-dark-palette.d.ts.map +1 -0
- package/esm/services/default-dark-palette.js +56 -0
- package/esm/services/default-dark-palette.js.map +1 -0
- package/esm/services/default-dark-theme.d.ts +3 -0
- package/esm/services/default-dark-theme.d.ts.map +1 -1
- package/esm/services/default-dark-theme.js +7 -4
- package/esm/services/default-dark-theme.js.map +1 -1
- package/esm/services/default-light-theme.d.ts +3 -0
- package/esm/services/default-light-theme.d.ts.map +1 -1
- package/esm/services/default-light-theme.js +3 -0
- package/esm/services/default-light-theme.js.map +1 -1
- package/esm/services/index.d.ts +1 -0
- package/esm/services/index.d.ts.map +1 -1
- package/esm/services/index.js +1 -0
- package/esm/services/index.js.map +1 -1
- package/esm/services/theme-provider-service.d.ts +10 -1
- package/esm/services/theme-provider-service.d.ts.map +1 -1
- package/esm/services/theme-provider-service.js.map +1 -1
- package/package.json +3 -3
- package/src/components/avatar.spec.tsx +4 -4
- package/src/components/avatar.tsx +3 -1
- package/src/components/icons/icon-definitions.spec.ts +28 -2
- package/src/components/icons/icon-definitions.ts +759 -0
- package/src/components/icons/icon-types.ts +12 -0
- package/src/components/icons/index.ts +1 -1
- package/src/components/index.ts +1 -0
- package/src/components/markdown/index.ts +4 -0
- package/src/components/markdown/markdown-display.spec.tsx +243 -0
- package/src/components/markdown/markdown-display.tsx +202 -0
- package/src/components/markdown/markdown-editor.spec.tsx +142 -0
- package/src/components/markdown/markdown-editor.tsx +167 -0
- package/src/components/markdown/markdown-input.spec.tsx +274 -0
- package/src/components/markdown/markdown-input.tsx +143 -0
- package/src/components/markdown/markdown-parser.spec.ts +258 -0
- package/src/components/markdown/markdown-parser.ts +333 -0
- package/src/components/page-container/index.tsx +1 -1
- package/src/components/page-container/page-header.tsx +5 -5
- package/src/components/styles.tsx +1 -0
- package/src/components/suggest/index.tsx +1 -1
- package/src/components/typography.tsx +28 -15
- package/src/services/css-variable-theme.spec.ts +3 -0
- package/src/services/css-variable-theme.ts +3 -0
- package/src/services/default-dark-palette.ts +57 -0
- package/src/services/default-dark-theme.ts +7 -4
- package/src/services/default-light-theme.ts +3 -0
- package/src/services/index.ts +1 -0
- package/src/services/theme-provider-service.ts +7 -1
|
@@ -8,6 +8,9 @@ export type IconPath = {
|
|
|
8
8
|
fillRule?: 'evenodd' | 'nonzero'
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Category for grouping icons in galleries */
|
|
12
|
+
export type IconCategory = 'Actions' | 'Navigation' | 'Status' | 'Content' | 'UI' | 'Common'
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Defines an icon as a set of SVG paths with rendering metadata.
|
|
13
16
|
* Icons are lightweight objects containing only path data -- no embedded SVG markup.
|
|
@@ -45,4 +48,13 @@ export type IconDefinition = {
|
|
|
45
48
|
* @default 2
|
|
46
49
|
*/
|
|
47
50
|
strokeWidth?: number
|
|
51
|
+
|
|
52
|
+
/** Human-readable display name (e.g., "Check Circle", "Arrow Left") */
|
|
53
|
+
name?: string
|
|
54
|
+
/** Short description of the icon's intended use */
|
|
55
|
+
description?: string
|
|
56
|
+
/** Search keywords for filtering (e.g., ['dismiss', 'cancel', 'x'] for close) */
|
|
57
|
+
keywords?: string[]
|
|
58
|
+
/** Category for grouping in galleries */
|
|
59
|
+
category?: IconCategory
|
|
48
60
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { IconDefinition, IconPath } from './icon-types.js'
|
|
1
|
+
export type { IconCategory, IconDefinition, IconPath } from './icon-types.js'
|
|
2
2
|
export { Icon } from './icon.js'
|
|
3
3
|
export type { IconProps } from './icon.js'
|
|
4
4
|
export * as icons from './icon-definitions.js'
|
package/src/components/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export * from './inputs/index.js'
|
|
|
29
29
|
export * from './linear-progress.js'
|
|
30
30
|
export * from './list/index.js'
|
|
31
31
|
export * from './loader.js'
|
|
32
|
+
export * from './markdown/index.js'
|
|
32
33
|
export * from './menu/index.js'
|
|
33
34
|
export * from './modal.js'
|
|
34
35
|
export * from './noty-list.js'
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { createComponent, initializeShadeRoot } from '@furystack/shades'
|
|
3
|
+
import { sleepAsync, usingAsync } from '@furystack/utils'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { MarkdownDisplay } from './markdown-display.js'
|
|
6
|
+
|
|
7
|
+
describe('MarkdownDisplay', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
document.body.innerHTML = '<div id="root"></div>'
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = ''
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should render with shadow DOM', async () => {
|
|
18
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
19
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
20
|
+
|
|
21
|
+
initializeShadeRoot({
|
|
22
|
+
injector,
|
|
23
|
+
rootElement,
|
|
24
|
+
jsxElement: <MarkdownDisplay content="Hello" />,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await sleepAsync(50)
|
|
28
|
+
|
|
29
|
+
const el = document.querySelector('shade-markdown-display')
|
|
30
|
+
expect(el).not.toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should render a heading', async () => {
|
|
35
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
36
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
37
|
+
|
|
38
|
+
initializeShadeRoot({
|
|
39
|
+
injector,
|
|
40
|
+
rootElement,
|
|
41
|
+
jsxElement: <MarkdownDisplay content="# Hello World" />,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await sleepAsync(50)
|
|
45
|
+
|
|
46
|
+
const typography = document.querySelector('shade-markdown-display shade-typography')
|
|
47
|
+
expect(typography).not.toBeNull()
|
|
48
|
+
expect(typography?.getAttribute('data-variant')).toBe('h1')
|
|
49
|
+
expect(typography?.textContent).toContain('Hello World')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should render a paragraph', async () => {
|
|
54
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
55
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
56
|
+
|
|
57
|
+
initializeShadeRoot({
|
|
58
|
+
injector,
|
|
59
|
+
rootElement,
|
|
60
|
+
jsxElement: <MarkdownDisplay content="Just a paragraph." />,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await sleepAsync(50)
|
|
64
|
+
|
|
65
|
+
const typography = document.querySelector('shade-markdown-display shade-typography[data-variant="body1"]')
|
|
66
|
+
expect(typography).not.toBeNull()
|
|
67
|
+
expect(typography?.textContent).toContain('Just a paragraph.')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should render a code block', async () => {
|
|
72
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
73
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
74
|
+
|
|
75
|
+
initializeShadeRoot({
|
|
76
|
+
injector,
|
|
77
|
+
rootElement,
|
|
78
|
+
jsxElement: <MarkdownDisplay content={'```js\nconsole.log("hi")\n```'} />,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await sleepAsync(50)
|
|
82
|
+
|
|
83
|
+
const codeBlock = document.querySelector('shade-markdown-display .md-code-block')
|
|
84
|
+
expect(codeBlock).not.toBeNull()
|
|
85
|
+
expect(codeBlock?.textContent).toContain('console.log("hi")')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should render a list', async () => {
|
|
90
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
91
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
92
|
+
|
|
93
|
+
initializeShadeRoot({
|
|
94
|
+
injector,
|
|
95
|
+
rootElement,
|
|
96
|
+
jsxElement: <MarkdownDisplay content={'- Item A\n- Item B'} />,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await sleepAsync(50)
|
|
100
|
+
|
|
101
|
+
const list = document.querySelector('shade-markdown-display ul')
|
|
102
|
+
expect(list).not.toBeNull()
|
|
103
|
+
const items = document.querySelectorAll('shade-markdown-display .md-list-item')
|
|
104
|
+
expect(items.length).toBe(2)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should render checkboxes as disabled when readOnly (default)', async () => {
|
|
109
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
110
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
111
|
+
|
|
112
|
+
initializeShadeRoot({
|
|
113
|
+
injector,
|
|
114
|
+
rootElement,
|
|
115
|
+
jsxElement: <MarkdownDisplay content="- [ ] Task" />,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
await sleepAsync(50)
|
|
119
|
+
|
|
120
|
+
const checkbox = document.querySelector('shade-markdown-display shade-checkbox')
|
|
121
|
+
expect(checkbox).not.toBeNull()
|
|
122
|
+
expect(checkbox?.hasAttribute('data-disabled')).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should render checkboxes as enabled when readOnly is false', async () => {
|
|
127
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
128
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
129
|
+
const onChange = vi.fn()
|
|
130
|
+
|
|
131
|
+
initializeShadeRoot({
|
|
132
|
+
injector,
|
|
133
|
+
rootElement,
|
|
134
|
+
jsxElement: <MarkdownDisplay content="- [ ] Task" readOnly={false} onChange={onChange} />,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await sleepAsync(50)
|
|
138
|
+
|
|
139
|
+
const checkbox = document.querySelector('shade-markdown-display shade-checkbox')
|
|
140
|
+
expect(checkbox).not.toBeNull()
|
|
141
|
+
expect(checkbox?.hasAttribute('data-disabled')).toBe(false)
|
|
142
|
+
|
|
143
|
+
const input = checkbox?.querySelector('input[type="checkbox"]') as HTMLInputElement
|
|
144
|
+
expect(input).not.toBeNull()
|
|
145
|
+
input.click()
|
|
146
|
+
|
|
147
|
+
await sleepAsync(50)
|
|
148
|
+
|
|
149
|
+
expect(onChange).toHaveBeenCalledOnce()
|
|
150
|
+
expect(onChange).toHaveBeenCalledWith('- [x] Task')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('should render a blockquote', async () => {
|
|
155
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
156
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
157
|
+
|
|
158
|
+
initializeShadeRoot({
|
|
159
|
+
injector,
|
|
160
|
+
rootElement,
|
|
161
|
+
jsxElement: <MarkdownDisplay content="> Quote text" />,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await sleepAsync(50)
|
|
165
|
+
|
|
166
|
+
const bq = document.querySelector('shade-markdown-display .md-blockquote')
|
|
167
|
+
expect(bq).not.toBeNull()
|
|
168
|
+
expect(bq?.textContent).toContain('Quote text')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('should render a horizontal rule', async () => {
|
|
173
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
174
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
175
|
+
|
|
176
|
+
initializeShadeRoot({
|
|
177
|
+
injector,
|
|
178
|
+
rootElement,
|
|
179
|
+
jsxElement: <MarkdownDisplay content="---" />,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await sleepAsync(50)
|
|
183
|
+
|
|
184
|
+
const hr = document.querySelector('shade-markdown-display .md-hr')
|
|
185
|
+
expect(hr).not.toBeNull()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('should render links', async () => {
|
|
190
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
191
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
192
|
+
|
|
193
|
+
initializeShadeRoot({
|
|
194
|
+
injector,
|
|
195
|
+
rootElement,
|
|
196
|
+
jsxElement: <MarkdownDisplay content="[Click here](https://example.com)" />,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await sleepAsync(50)
|
|
200
|
+
|
|
201
|
+
const link = document.querySelector('shade-markdown-display .md-link') as HTMLAnchorElement
|
|
202
|
+
expect(link).not.toBeNull()
|
|
203
|
+
expect(link?.href).toContain('example.com')
|
|
204
|
+
expect(link?.textContent).toContain('Click here')
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('should render images', async () => {
|
|
209
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
210
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
211
|
+
|
|
212
|
+
initializeShadeRoot({
|
|
213
|
+
injector,
|
|
214
|
+
rootElement,
|
|
215
|
+
jsxElement: <MarkdownDisplay content="" />,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
await sleepAsync(50)
|
|
219
|
+
|
|
220
|
+
const img = document.querySelector('shade-markdown-display .md-image') as HTMLImageElement
|
|
221
|
+
expect(img).not.toBeNull()
|
|
222
|
+
expect(img?.alt).toBe('alt text')
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('should render empty for empty content', async () => {
|
|
227
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
228
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
229
|
+
|
|
230
|
+
initializeShadeRoot({
|
|
231
|
+
injector,
|
|
232
|
+
rootElement,
|
|
233
|
+
jsxElement: <MarkdownDisplay content="" />,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
await sleepAsync(50)
|
|
237
|
+
|
|
238
|
+
const root = document.querySelector('shade-markdown-display .md-root')
|
|
239
|
+
expect(root).not.toBeNull()
|
|
240
|
+
expect(root?.children.length).toBe(0)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Shade, createComponent } from '@furystack/shades'
|
|
2
|
+
import { cssVariableTheme } from '../../services/css-variable-theme.js'
|
|
3
|
+
import { Checkbox } from '../inputs/checkbox.js'
|
|
4
|
+
import { Typography } from '../typography.js'
|
|
5
|
+
import type { InlineNode, MarkdownNode } from './markdown-parser.js'
|
|
6
|
+
import { parseMarkdown, toggleCheckbox } from './markdown-parser.js'
|
|
7
|
+
|
|
8
|
+
export type MarkdownDisplayProps = {
|
|
9
|
+
/** The raw Markdown string to render */
|
|
10
|
+
content: string
|
|
11
|
+
/** When false, checkboxes can be toggled. Defaults to true. */
|
|
12
|
+
readOnly?: boolean
|
|
13
|
+
/** Called with the updated Markdown string when a checkbox is toggled */
|
|
14
|
+
onChange?: (newContent: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const renderInline = (nodes: InlineNode[]): JSX.Element => {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
{nodes.map((node) => {
|
|
21
|
+
switch (node.type) {
|
|
22
|
+
case 'text':
|
|
23
|
+
return <>{node.content}</>
|
|
24
|
+
case 'bold':
|
|
25
|
+
return <strong>{renderInline(node.children)}</strong>
|
|
26
|
+
case 'italic':
|
|
27
|
+
return <em>{renderInline(node.children)}</em>
|
|
28
|
+
case 'code':
|
|
29
|
+
return <code className="md-inline-code">{node.content}</code>
|
|
30
|
+
case 'link':
|
|
31
|
+
return (
|
|
32
|
+
<a className="md-link" href={node.href} target="_blank" rel="noopener noreferrer">
|
|
33
|
+
{renderInline(node.children)}
|
|
34
|
+
</a>
|
|
35
|
+
)
|
|
36
|
+
case 'image':
|
|
37
|
+
return <img className="md-image" src={node.src} alt={node.alt} />
|
|
38
|
+
default:
|
|
39
|
+
return <></>
|
|
40
|
+
}
|
|
41
|
+
})}
|
|
42
|
+
</>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const variantForLevel = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
|
47
|
+
const map = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4', 5: 'h5', 6: 'h6' } as const
|
|
48
|
+
return map[level]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const renderBlock = (
|
|
52
|
+
node: MarkdownNode,
|
|
53
|
+
_index: number,
|
|
54
|
+
options: { content: string; readOnly: boolean; onChange?: (newContent: string) => void },
|
|
55
|
+
): JSX.Element => {
|
|
56
|
+
switch (node.type) {
|
|
57
|
+
case 'heading':
|
|
58
|
+
return <Typography variant={variantForLevel(node.level)}>{renderInline(node.children)}</Typography>
|
|
59
|
+
case 'paragraph':
|
|
60
|
+
return <Typography variant="body1">{renderInline(node.children)}</Typography>
|
|
61
|
+
case 'codeBlock':
|
|
62
|
+
return (
|
|
63
|
+
<pre className="md-code-block" data-language={node.language || undefined}>
|
|
64
|
+
<code>{node.content}</code>
|
|
65
|
+
</pre>
|
|
66
|
+
)
|
|
67
|
+
case 'blockquote':
|
|
68
|
+
return (
|
|
69
|
+
<blockquote className="md-blockquote">
|
|
70
|
+
{node.children.map((child, i) => renderBlock(child, i, options))}
|
|
71
|
+
</blockquote>
|
|
72
|
+
)
|
|
73
|
+
case 'horizontalRule':
|
|
74
|
+
return <hr className="md-hr" />
|
|
75
|
+
case 'list': {
|
|
76
|
+
const listItems = node.items.map((item) => {
|
|
77
|
+
if (item.checkbox !== undefined) {
|
|
78
|
+
const handleChange = () => {
|
|
79
|
+
if (!options.readOnly && options.onChange) {
|
|
80
|
+
options.onChange(toggleCheckbox(options.content, item.sourceLineIndex))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<li className="md-list-item md-checkbox-item" data-source-line={String(item.sourceLineIndex)}>
|
|
85
|
+
<Checkbox checked={item.checkbox === 'checked'} disabled={options.readOnly} onchange={handleChange} />
|
|
86
|
+
<span className="md-checkbox-label">{renderInline(item.children)}</span>
|
|
87
|
+
</li>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
return <li className="md-list-item">{renderInline(item.children)}</li>
|
|
91
|
+
})
|
|
92
|
+
if (node.ordered) {
|
|
93
|
+
return <ol className="md-list">{listItems}</ol>
|
|
94
|
+
}
|
|
95
|
+
return <ul className="md-list">{listItems}</ul>
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
return <></>
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Renders a Markdown string using FuryStack Shades components.
|
|
104
|
+
* Supports headings, paragraphs, lists, checkboxes, code blocks,
|
|
105
|
+
* blockquotes, images, links, and horizontal rules.
|
|
106
|
+
*/
|
|
107
|
+
export const MarkdownDisplay = Shade<MarkdownDisplayProps>({
|
|
108
|
+
shadowDomName: 'shade-markdown-display',
|
|
109
|
+
css: {
|
|
110
|
+
display: 'block',
|
|
111
|
+
fontFamily: cssVariableTheme.typography.fontFamily,
|
|
112
|
+
color: cssVariableTheme.text.primary,
|
|
113
|
+
lineHeight: cssVariableTheme.typography.lineHeight.relaxed,
|
|
114
|
+
|
|
115
|
+
'& .md-inline-code': {
|
|
116
|
+
fontFamily: 'monospace',
|
|
117
|
+
backgroundColor: cssVariableTheme.action.hoverBackground,
|
|
118
|
+
padding: '2px 6px',
|
|
119
|
+
borderRadius: cssVariableTheme.shape.borderRadius.xs,
|
|
120
|
+
fontSize: '0.9em',
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
'& .md-code-block': {
|
|
124
|
+
fontFamily: 'monospace',
|
|
125
|
+
backgroundColor: cssVariableTheme.background.default,
|
|
126
|
+
border: `1px solid ${cssVariableTheme.action.subtleBorder}`,
|
|
127
|
+
borderRadius: cssVariableTheme.shape.borderRadius.md,
|
|
128
|
+
padding: cssVariableTheme.spacing.md,
|
|
129
|
+
overflow: 'auto',
|
|
130
|
+
fontSize: cssVariableTheme.typography.fontSize.sm,
|
|
131
|
+
margin: `${cssVariableTheme.spacing.sm} 0`,
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
'& .md-code-block code': {
|
|
135
|
+
font: 'inherit',
|
|
136
|
+
whiteSpace: 'pre',
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
'& .md-blockquote': {
|
|
140
|
+
borderLeft: `4px solid ${cssVariableTheme.palette.primary.main}`,
|
|
141
|
+
margin: `${cssVariableTheme.spacing.sm} 0`,
|
|
142
|
+
padding: `${cssVariableTheme.spacing.sm} ${cssVariableTheme.spacing.md}`,
|
|
143
|
+
color: cssVariableTheme.text.secondary,
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
'& .md-link': {
|
|
147
|
+
color: cssVariableTheme.palette.primary.main,
|
|
148
|
+
textDecoration: 'none',
|
|
149
|
+
},
|
|
150
|
+
'& .md-link:hover': {
|
|
151
|
+
textDecoration: 'underline',
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
'& .md-image': {
|
|
155
|
+
maxWidth: '100%',
|
|
156
|
+
borderRadius: cssVariableTheme.shape.borderRadius.md,
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
'& .md-hr': {
|
|
160
|
+
border: 'none',
|
|
161
|
+
borderTop: `1px solid ${cssVariableTheme.divider}`,
|
|
162
|
+
margin: `${cssVariableTheme.spacing.md} 0`,
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
'& .md-list': {
|
|
166
|
+
paddingLeft: cssVariableTheme.spacing.xl,
|
|
167
|
+
margin: `${cssVariableTheme.spacing.sm} 0`,
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
'& .md-list-item': {
|
|
171
|
+
marginBottom: cssVariableTheme.spacing.xs,
|
|
172
|
+
fontSize: cssVariableTheme.typography.fontSize.md,
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
'& .md-checkbox-item': {
|
|
176
|
+
listStyle: 'none',
|
|
177
|
+
display: 'flex',
|
|
178
|
+
alignItems: 'center',
|
|
179
|
+
gap: cssVariableTheme.spacing.sm,
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
'& .md-checkbox-label': {
|
|
183
|
+
fontSize: cssVariableTheme.typography.fontSize.md,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
render: ({ props }) => {
|
|
187
|
+
const readOnly = props.readOnly !== false
|
|
188
|
+
const ast = parseMarkdown(props.content)
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="md-root">
|
|
192
|
+
{ast.map((node, i) =>
|
|
193
|
+
renderBlock(node, i, {
|
|
194
|
+
content: props.content,
|
|
195
|
+
readOnly,
|
|
196
|
+
onChange: props.onChange,
|
|
197
|
+
}),
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
},
|
|
202
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { createComponent, initializeShadeRoot } from '@furystack/shades'
|
|
3
|
+
import { sleepAsync, usingAsync } from '@furystack/utils'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { MarkdownEditor } from './markdown-editor.js'
|
|
6
|
+
|
|
7
|
+
describe('MarkdownEditor', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
document.body.innerHTML = '<div id="root"></div>'
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = ''
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should render with shadow DOM', async () => {
|
|
18
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
19
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
20
|
+
|
|
21
|
+
initializeShadeRoot({
|
|
22
|
+
injector,
|
|
23
|
+
rootElement,
|
|
24
|
+
jsxElement: <MarkdownEditor value="# Hello" />,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await sleepAsync(50)
|
|
28
|
+
|
|
29
|
+
const el = document.querySelector('shade-markdown-editor')
|
|
30
|
+
expect(el).not.toBeNull()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('side-by-side layout (default)', () => {
|
|
35
|
+
it('should render input and preview panes side by side', async () => {
|
|
36
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
37
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
38
|
+
|
|
39
|
+
initializeShadeRoot({
|
|
40
|
+
injector,
|
|
41
|
+
rootElement,
|
|
42
|
+
jsxElement: <MarkdownEditor value="# Hello" />,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
await sleepAsync(50)
|
|
46
|
+
|
|
47
|
+
const split = document.querySelector('.md-editor-split') as HTMLElement
|
|
48
|
+
expect(split).not.toBeNull()
|
|
49
|
+
expect(split.dataset.layout).toBe('side-by-side')
|
|
50
|
+
|
|
51
|
+
const input = document.querySelector('shade-markdown-editor shade-markdown-input')
|
|
52
|
+
expect(input).not.toBeNull()
|
|
53
|
+
|
|
54
|
+
const display = document.querySelector('shade-markdown-editor shade-markdown-display')
|
|
55
|
+
expect(display).not.toBeNull()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('above-below layout', () => {
|
|
61
|
+
it('should render with above-below layout', async () => {
|
|
62
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
63
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
64
|
+
|
|
65
|
+
initializeShadeRoot({
|
|
66
|
+
injector,
|
|
67
|
+
rootElement,
|
|
68
|
+
jsxElement: <MarkdownEditor value="# Hello" layout="above-below" />,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await sleepAsync(50)
|
|
72
|
+
|
|
73
|
+
const split = document.querySelector('.md-editor-split') as HTMLElement
|
|
74
|
+
expect(split).not.toBeNull()
|
|
75
|
+
expect(split.dataset.layout).toBe('above-below')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('tabs layout', () => {
|
|
81
|
+
it('should render with tabs layout', async () => {
|
|
82
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
83
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
84
|
+
|
|
85
|
+
initializeShadeRoot({
|
|
86
|
+
injector,
|
|
87
|
+
rootElement,
|
|
88
|
+
jsxElement: <MarkdownEditor value="# Hello" layout="tabs" />,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
await sleepAsync(50)
|
|
92
|
+
|
|
93
|
+
const tabs = document.querySelector('shade-markdown-editor shade-tabs')
|
|
94
|
+
expect(tabs).not.toBeNull()
|
|
95
|
+
|
|
96
|
+
const tabButtons = document.querySelectorAll('shade-markdown-editor .shade-tab-btn')
|
|
97
|
+
expect(tabButtons.length).toBe(2)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should show the edit tab by default', async () => {
|
|
102
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
103
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
104
|
+
|
|
105
|
+
initializeShadeRoot({
|
|
106
|
+
injector,
|
|
107
|
+
rootElement,
|
|
108
|
+
jsxElement: <MarkdownEditor value="# Hello" layout="tabs" />,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
await sleepAsync(50)
|
|
112
|
+
|
|
113
|
+
const input = document.querySelector('shade-markdown-editor shade-markdown-input')
|
|
114
|
+
expect(input).not.toBeNull()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should pass value to both input and display', async () => {
|
|
120
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
121
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
122
|
+
const mdContent = '# Test Content'
|
|
123
|
+
|
|
124
|
+
initializeShadeRoot({
|
|
125
|
+
injector,
|
|
126
|
+
rootElement,
|
|
127
|
+
jsxElement: <MarkdownEditor value={mdContent} />,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await sleepAsync(50)
|
|
131
|
+
|
|
132
|
+
const textarea = document.querySelector('shade-markdown-editor textarea') as HTMLTextAreaElement
|
|
133
|
+
expect(textarea.value).toBe(mdContent)
|
|
134
|
+
|
|
135
|
+
const heading = document.querySelector(
|
|
136
|
+
'shade-markdown-editor shade-markdown-display shade-typography[data-variant="h1"]',
|
|
137
|
+
)
|
|
138
|
+
expect(heading).not.toBeNull()
|
|
139
|
+
expect(heading?.textContent).toContain('Test Content')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
})
|