@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/esm/components/avatar.d.ts.map +1 -1
  3. package/esm/components/avatar.js +3 -1
  4. package/esm/components/avatar.js.map +1 -1
  5. package/esm/components/avatar.spec.js +4 -4
  6. package/esm/components/avatar.spec.js.map +1 -1
  7. package/esm/components/icons/icon-definitions.d.ts +82 -0
  8. package/esm/components/icons/icon-definitions.d.ts.map +1 -1
  9. package/esm/components/icons/icon-definitions.js +717 -0
  10. package/esm/components/icons/icon-definitions.js.map +1 -1
  11. package/esm/components/icons/icon-definitions.spec.js +22 -2
  12. package/esm/components/icons/icon-definitions.spec.js.map +1 -1
  13. package/esm/components/icons/icon-types.d.ts +10 -0
  14. package/esm/components/icons/icon-types.d.ts.map +1 -1
  15. package/esm/components/icons/index.d.ts +1 -1
  16. package/esm/components/icons/index.d.ts.map +1 -1
  17. package/esm/components/index.d.ts +1 -0
  18. package/esm/components/index.d.ts.map +1 -1
  19. package/esm/components/index.js +1 -0
  20. package/esm/components/index.js.map +1 -1
  21. package/esm/components/markdown/index.d.ts +5 -0
  22. package/esm/components/markdown/index.d.ts.map +1 -0
  23. package/esm/components/markdown/index.js +5 -0
  24. package/esm/components/markdown/index.js.map +1 -0
  25. package/esm/components/markdown/markdown-display.d.ts +19 -0
  26. package/esm/components/markdown/markdown-display.d.ts.map +1 -0
  27. package/esm/components/markdown/markdown-display.js +149 -0
  28. package/esm/components/markdown/markdown-display.js.map +1 -0
  29. package/esm/components/markdown/markdown-display.spec.d.ts +2 -0
  30. package/esm/components/markdown/markdown-display.spec.d.ts.map +1 -0
  31. package/esm/components/markdown/markdown-display.spec.js +191 -0
  32. package/esm/components/markdown/markdown-display.spec.js.map +1 -0
  33. package/esm/components/markdown/markdown-editor.d.ts +25 -0
  34. package/esm/components/markdown/markdown-editor.d.ts.map +1 -0
  35. package/esm/components/markdown/markdown-editor.js +113 -0
  36. package/esm/components/markdown/markdown-editor.js.map +1 -0
  37. package/esm/components/markdown/markdown-editor.spec.d.ts +2 -0
  38. package/esm/components/markdown/markdown-editor.spec.d.ts.map +1 -0
  39. package/esm/components/markdown/markdown-editor.spec.js +111 -0
  40. package/esm/components/markdown/markdown-editor.spec.js.map +1 -0
  41. package/esm/components/markdown/markdown-input.d.ts +29 -0
  42. package/esm/components/markdown/markdown-input.d.ts.map +1 -0
  43. package/esm/components/markdown/markdown-input.js +100 -0
  44. package/esm/components/markdown/markdown-input.js.map +1 -0
  45. package/esm/components/markdown/markdown-input.spec.d.ts +2 -0
  46. package/esm/components/markdown/markdown-input.spec.d.ts.map +1 -0
  47. package/esm/components/markdown/markdown-input.spec.js +215 -0
  48. package/esm/components/markdown/markdown-input.spec.js.map +1 -0
  49. package/esm/components/markdown/markdown-parser.d.ts +82 -0
  50. package/esm/components/markdown/markdown-parser.d.ts.map +1 -0
  51. package/esm/components/markdown/markdown-parser.js +274 -0
  52. package/esm/components/markdown/markdown-parser.js.map +1 -0
  53. package/esm/components/markdown/markdown-parser.spec.d.ts +2 -0
  54. package/esm/components/markdown/markdown-parser.spec.d.ts.map +1 -0
  55. package/esm/components/markdown/markdown-parser.spec.js +229 -0
  56. package/esm/components/markdown/markdown-parser.spec.js.map +1 -0
  57. package/esm/components/page-container/index.d.ts +1 -1
  58. package/esm/components/page-container/index.js +1 -1
  59. package/esm/components/page-container/page-header.d.ts +5 -5
  60. package/esm/components/page-container/page-header.d.ts.map +1 -1
  61. package/esm/components/page-container/page-header.js +3 -3
  62. package/esm/components/styles.d.ts +1 -0
  63. package/esm/components/styles.d.ts.map +1 -1
  64. package/esm/components/styles.js.map +1 -1
  65. package/esm/components/suggest/index.d.ts +1 -1
  66. package/esm/components/suggest/index.d.ts.map +1 -1
  67. package/esm/components/typography.d.ts.map +1 -1
  68. package/esm/components/typography.js +26 -14
  69. package/esm/components/typography.js.map +1 -1
  70. package/esm/services/css-variable-theme.d.ts +3 -0
  71. package/esm/services/css-variable-theme.d.ts.map +1 -1
  72. package/esm/services/css-variable-theme.js +3 -0
  73. package/esm/services/css-variable-theme.js.map +1 -1
  74. package/esm/services/css-variable-theme.spec.js +3 -0
  75. package/esm/services/css-variable-theme.spec.js.map +1 -1
  76. package/esm/services/default-dark-palette.d.ts +8 -0
  77. package/esm/services/default-dark-palette.d.ts.map +1 -0
  78. package/esm/services/default-dark-palette.js +56 -0
  79. package/esm/services/default-dark-palette.js.map +1 -0
  80. package/esm/services/default-dark-theme.d.ts +3 -0
  81. package/esm/services/default-dark-theme.d.ts.map +1 -1
  82. package/esm/services/default-dark-theme.js +7 -4
  83. package/esm/services/default-dark-theme.js.map +1 -1
  84. package/esm/services/default-light-theme.d.ts +3 -0
  85. package/esm/services/default-light-theme.d.ts.map +1 -1
  86. package/esm/services/default-light-theme.js +3 -0
  87. package/esm/services/default-light-theme.js.map +1 -1
  88. package/esm/services/index.d.ts +1 -0
  89. package/esm/services/index.d.ts.map +1 -1
  90. package/esm/services/index.js +1 -0
  91. package/esm/services/index.js.map +1 -1
  92. package/esm/services/theme-provider-service.d.ts +10 -1
  93. package/esm/services/theme-provider-service.d.ts.map +1 -1
  94. package/esm/services/theme-provider-service.js.map +1 -1
  95. package/package.json +3 -3
  96. package/src/components/avatar.spec.tsx +4 -4
  97. package/src/components/avatar.tsx +3 -1
  98. package/src/components/icons/icon-definitions.spec.ts +28 -2
  99. package/src/components/icons/icon-definitions.ts +759 -0
  100. package/src/components/icons/icon-types.ts +12 -0
  101. package/src/components/icons/index.ts +1 -1
  102. package/src/components/index.ts +1 -0
  103. package/src/components/markdown/index.ts +4 -0
  104. package/src/components/markdown/markdown-display.spec.tsx +243 -0
  105. package/src/components/markdown/markdown-display.tsx +202 -0
  106. package/src/components/markdown/markdown-editor.spec.tsx +142 -0
  107. package/src/components/markdown/markdown-editor.tsx +167 -0
  108. package/src/components/markdown/markdown-input.spec.tsx +274 -0
  109. package/src/components/markdown/markdown-input.tsx +143 -0
  110. package/src/components/markdown/markdown-parser.spec.ts +258 -0
  111. package/src/components/markdown/markdown-parser.ts +333 -0
  112. package/src/components/page-container/index.tsx +1 -1
  113. package/src/components/page-container/page-header.tsx +5 -5
  114. package/src/components/styles.tsx +1 -0
  115. package/src/components/suggest/index.tsx +1 -1
  116. package/src/components/typography.tsx +28 -15
  117. package/src/services/css-variable-theme.spec.ts +3 -0
  118. package/src/services/css-variable-theme.ts +3 -0
  119. package/src/services/default-dark-palette.ts +57 -0
  120. package/src/services/default-dark-theme.ts +7 -4
  121. package/src/services/default-light-theme.ts +3 -0
  122. package/src/services/index.ts +1 -0
  123. 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'
@@ -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,4 @@
1
+ export * from './markdown-parser.js'
2
+ export * from './markdown-display.js'
3
+ export * from './markdown-input.js'
4
+ export * from './markdown-editor.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="![alt text](image.png)" />,
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
+ })