@fragments-sdk/ui 0.10.0 → 0.11.1

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 (58) hide show
  1. package/dist/assets/ui.css +329 -26
  2. package/dist/blocks/BlogEditor.block.d.ts +3 -0
  3. package/dist/blocks/BlogEditor.block.d.ts.map +1 -0
  4. package/dist/codeblock.cjs +37 -10
  5. package/dist/codeblock.cjs.map +1 -1
  6. package/dist/codeblock.js +15 -10
  7. package/dist/codeblock.js.map +1 -1
  8. package/dist/components/AppShell/AppShell.module.scss.cjs +14 -14
  9. package/dist/components/AppShell/AppShell.module.scss.js +14 -14
  10. package/dist/components/CodeBlock/index.d.ts.map +1 -1
  11. package/dist/components/Drawer/index.cjs +2 -1
  12. package/dist/components/Drawer/index.cjs.map +1 -1
  13. package/dist/components/Drawer/index.d.ts +3 -1
  14. package/dist/components/Drawer/index.d.ts.map +1 -1
  15. package/dist/components/Drawer/index.js +2 -1
  16. package/dist/components/Drawer/index.js.map +1 -1
  17. package/dist/components/Editor/Editor.module.scss.cjs +57 -0
  18. package/dist/components/Editor/Editor.module.scss.cjs.map +1 -0
  19. package/dist/components/Editor/Editor.module.scss.js +57 -0
  20. package/dist/components/Editor/Editor.module.scss.js.map +1 -0
  21. package/dist/components/Editor/index.cjs +548 -0
  22. package/dist/components/Editor/index.cjs.map +1 -0
  23. package/dist/components/Editor/index.d.ts +107 -0
  24. package/dist/components/Editor/index.d.ts.map +1 -0
  25. package/dist/components/Editor/index.js +531 -0
  26. package/dist/components/Editor/index.js.map +1 -0
  27. package/dist/components/Sidebar/index.cjs +14 -16
  28. package/dist/components/Sidebar/index.cjs.map +1 -1
  29. package/dist/components/Sidebar/index.d.ts +4 -6
  30. package/dist/components/Sidebar/index.d.ts.map +1 -1
  31. package/dist/components/Sidebar/index.js +14 -16
  32. package/dist/components/Sidebar/index.js.map +1 -1
  33. package/dist/index.cjs +22 -0
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.ts +2 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +22 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/utils/keyboard-shortcuts.cjs +295 -0
  40. package/dist/utils/keyboard-shortcuts.cjs.map +1 -0
  41. package/dist/utils/keyboard-shortcuts.d.ts +293 -0
  42. package/dist/utils/keyboard-shortcuts.d.ts.map +1 -0
  43. package/dist/utils/keyboard-shortcuts.js +295 -0
  44. package/dist/utils/keyboard-shortcuts.js.map +1 -0
  45. package/fragments.json +1 -1
  46. package/package.json +28 -3
  47. package/src/blocks/BlogEditor.block.ts +34 -0
  48. package/src/components/AppShell/AppShell.module.scss +12 -13
  49. package/src/components/CodeBlock/index.tsx +15 -11
  50. package/src/components/Drawer/index.tsx +4 -1
  51. package/src/components/Editor/Editor.fragment.tsx +322 -0
  52. package/src/components/Editor/Editor.module.scss +333 -0
  53. package/src/components/Editor/Editor.test.tsx +174 -0
  54. package/src/components/Editor/index.tsx +815 -0
  55. package/src/components/Sidebar/index.tsx +16 -22
  56. package/src/index.ts +43 -0
  57. package/src/utils/keyboard-shortcuts.test.ts +357 -0
  58. package/src/utils/keyboard-shortcuts.ts +502 -0
@@ -0,0 +1,333 @@
1
+ @use '../../tokens/variables' as *;
2
+ @use '../../tokens/mixins' as *;
3
+
4
+ // ============================================
5
+ // Editor Root
6
+ // ============================================
7
+
8
+ .editor {
9
+ @include surface-elevated;
10
+ display: flex;
11
+ flex-direction: column;
12
+ min-width: 400px;
13
+ overflow: hidden;
14
+
15
+ // Focus ring (Enhancement #7)
16
+ &:focus-within:not([data-disabled]) {
17
+ @include focus-ring;
18
+ }
19
+
20
+ &[data-disabled] {
21
+ opacity: 0.5;
22
+ pointer-events: none;
23
+ }
24
+
25
+ &[data-readonly] {
26
+ .contentTextarea {
27
+ cursor: default;
28
+ }
29
+ }
30
+
31
+ // Size variants (Enhancement #2)
32
+ &[data-size='sm'] {
33
+ .content,
34
+ .contentTextarea,
35
+ :global(.tiptap) {
36
+ min-height: 120px;
37
+ }
38
+ }
39
+
40
+ &[data-size='md'] {
41
+ .content,
42
+ .contentTextarea,
43
+ :global(.tiptap) {
44
+ min-height: 200px;
45
+ }
46
+ }
47
+
48
+ &[data-size='lg'] {
49
+ .content,
50
+ .contentTextarea,
51
+ :global(.tiptap) {
52
+ min-height: 400px;
53
+ }
54
+ }
55
+ }
56
+
57
+ // ============================================
58
+ // Toolbar
59
+ // ============================================
60
+
61
+ .toolbar {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+ flex-wrap: wrap;
66
+ gap: var(--fui-space-2, $fui-space-2);
67
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
68
+ border-bottom: 1px solid var(--fui-border, $fui-border);
69
+ background-color: var(--fui-bg-secondary, $fui-bg-secondary);
70
+ }
71
+
72
+ // ============================================
73
+ // Toolbar Group
74
+ // ============================================
75
+
76
+ .toolbarGroup {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: var(--fui-space-0-5, $fui-space-0-5);
80
+ }
81
+
82
+ // ============================================
83
+ // Toolbar Button
84
+ // ============================================
85
+
86
+ .toolbarButton {
87
+ @include button-reset;
88
+ @include interactive-base;
89
+ @include touch-target;
90
+
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ width: 2rem;
95
+ height: 2rem;
96
+ border-radius: var(--fui-radius-md, $fui-radius-md);
97
+ background-color: transparent;
98
+ color: var(--fui-text-secondary, $fui-text-secondary);
99
+
100
+ &:hover:not(:disabled) {
101
+ background-color: var(--fui-bg-hover, $fui-bg-hover);
102
+ color: var(--fui-text-primary, $fui-text-primary);
103
+ }
104
+
105
+ &:active:not(:disabled) {
106
+ background-color: var(--fui-bg-active, $fui-bg-active);
107
+ }
108
+
109
+ svg {
110
+ width: 1rem;
111
+ height: 1rem;
112
+ }
113
+ }
114
+
115
+ .toolbarButtonActive {
116
+ color: var(--fui-color-accent, $fui-color-accent);
117
+ background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
118
+
119
+ &:hover:not(:disabled) {
120
+ color: var(--fui-color-accent-hover, $fui-color-accent-hover);
121
+ }
122
+ }
123
+
124
+ // ============================================
125
+ // Separator
126
+ // ============================================
127
+
128
+ .separator {
129
+ width: 1px;
130
+ height: 1rem;
131
+ background-color: var(--fui-border, $fui-border);
132
+ margin: 0 var(--fui-space-1, $fui-space-1);
133
+ flex-shrink: 0;
134
+ }
135
+
136
+ // ============================================
137
+ // Status Indicator
138
+ // ============================================
139
+
140
+ .statusIndicator {
141
+ @include section-label-text;
142
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
143
+ white-space: nowrap;
144
+ padding: 0 var(--fui-space-2, $fui-space-2);
145
+ }
146
+
147
+ .statusError {
148
+ color: var(--fui-color-danger, $fui-color-danger);
149
+ }
150
+
151
+ // ============================================
152
+ // Content
153
+ // ============================================
154
+
155
+ .content {
156
+ flex: 1;
157
+ min-height: 200px;
158
+ overflow-y: auto;
159
+ }
160
+
161
+ .contentRich {
162
+ // TipTap editor styling
163
+ :global(.tiptap) {
164
+ padding: var(--fui-padding-inline-md, $fui-padding-inline-md);
165
+ min-height: 200px;
166
+ outline: none;
167
+ @include text-base;
168
+ line-height: var(--fui-line-height-relaxed, $fui-line-height-relaxed);
169
+ font-size: var(--fui-font-size-base, $fui-font-size-base);
170
+
171
+ &:focus-visible {
172
+ outline: none;
173
+ }
174
+
175
+ // Placeholder
176
+ p.is-editor-empty:first-child::before {
177
+ content: attr(data-placeholder);
178
+ float: left;
179
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
180
+ pointer-events: none;
181
+ height: 0;
182
+ }
183
+
184
+ // Headings (Enhancement #4)
185
+ h1 {
186
+ font-size: var(--fui-font-size-2xl, $fui-font-size-2xl);
187
+ font-weight: var(--fui-font-weight-semibold, $fui-font-weight-semibold);
188
+ line-height: var(--fui-line-height-tight, $fui-line-height-tight);
189
+ margin: 0 0 var(--fui-space-3, $fui-space-3);
190
+ }
191
+
192
+ h2 {
193
+ font-size: var(--fui-font-size-xl, $fui-font-size-xl);
194
+ font-weight: var(--fui-font-weight-semibold, $fui-font-weight-semibold);
195
+ line-height: var(--fui-line-height-tight, $fui-line-height-tight);
196
+ margin: 0 0 var(--fui-space-2, $fui-space-2);
197
+ }
198
+
199
+ h3 {
200
+ font-size: var(--fui-font-size-lg, $fui-font-size-lg);
201
+ font-weight: var(--fui-font-weight-semibold, $fui-font-weight-semibold);
202
+ line-height: var(--fui-line-height-tight, $fui-line-height-tight);
203
+ margin: 0 0 var(--fui-space-2, $fui-space-2);
204
+ }
205
+
206
+ // Blockquote (Enhancement #5)
207
+ blockquote {
208
+ border-left: 3px solid var(--fui-color-accent, $fui-color-accent);
209
+ padding-left: var(--fui-space-4, $fui-space-4);
210
+ margin: 0 0 var(--fui-space-2, $fui-space-2);
211
+ color: var(--fui-text-secondary, $fui-text-secondary);
212
+ font-style: italic;
213
+ }
214
+
215
+ // Inline code
216
+ code {
217
+ background-color: var(--fui-bg-tertiary, $fui-bg-tertiary);
218
+ border-radius: var(--fui-radius-sm, $fui-radius-sm);
219
+ padding: 0.15em 0.3em;
220
+ font-family: var(--fui-font-mono, $fui-font-mono);
221
+ font-size: 0.9em;
222
+ }
223
+
224
+ // Links
225
+ a {
226
+ color: var(--fui-color-accent, $fui-color-accent);
227
+ text-decoration: underline;
228
+ cursor: pointer;
229
+
230
+ &:hover {
231
+ color: var(--fui-color-accent-hover, $fui-color-accent-hover);
232
+ }
233
+ }
234
+
235
+ // Unordered lists
236
+ ul {
237
+ padding-left: var(--fui-space-5, $fui-space-5);
238
+ list-style-type: disc;
239
+ }
240
+
241
+ // Ordered lists (Enhancement #3)
242
+ ol {
243
+ padding-left: var(--fui-space-5, $fui-space-5);
244
+ list-style-type: decimal;
245
+ }
246
+
247
+ // Strikethrough
248
+ s {
249
+ text-decoration: line-through;
250
+ }
251
+
252
+ // Paragraphs
253
+ p {
254
+ margin: 0 0 var(--fui-space-2, $fui-space-2);
255
+
256
+ &:last-child {
257
+ margin-bottom: 0;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ .contentTextarea {
264
+ @include button-reset;
265
+ @include text-base;
266
+
267
+ display: block;
268
+ width: 100%;
269
+ height: 100%;
270
+ min-height: 200px;
271
+ padding: var(--fui-padding-inline-md, $fui-padding-inline-md);
272
+ background: transparent;
273
+ border: none;
274
+ resize: none;
275
+ overflow-y: auto;
276
+ line-height: var(--fui-line-height-relaxed, $fui-line-height-relaxed);
277
+ font-size: var(--fui-font-size-base, $fui-font-size-base);
278
+
279
+ &::placeholder {
280
+ color: var(--fui-text-tertiary, $fui-text-tertiary);
281
+ }
282
+
283
+ &:focus-visible {
284
+ outline: none;
285
+ }
286
+
287
+ &:disabled {
288
+ cursor: not-allowed;
289
+ }
290
+ }
291
+
292
+ // ============================================
293
+ // Status Bar
294
+ // ============================================
295
+
296
+ .statusBar {
297
+ display: flex;
298
+ align-items: center;
299
+ justify-content: space-between;
300
+ gap: var(--fui-space-2, $fui-space-2);
301
+ padding: var(--fui-padding-item-xs, $fui-padding-item-xs) var(--fui-padding-item-sm, $fui-padding-item-sm);
302
+ border-top: 1px solid var(--fui-border, $fui-border);
303
+ background-color: var(--fui-bg-secondary, $fui-bg-secondary);
304
+ }
305
+
306
+ .statusBarLeft {
307
+ display: flex;
308
+ align-items: center;
309
+ gap: var(--fui-space-2, $fui-space-2);
310
+ }
311
+
312
+ .statusBarRight {
313
+ display: flex;
314
+ align-items: center;
315
+ gap: var(--fui-space-2, $fui-space-2);
316
+ margin-left: auto;
317
+ }
318
+
319
+ .statusBarItem {
320
+ @include helper-text;
321
+ white-space: nowrap;
322
+ }
323
+
324
+ // maxLength indicator states (Enhancement #1)
325
+ .statusBarItemWarning {
326
+ color: var(--fui-color-warning, $fui-color-warning);
327
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
328
+ }
329
+
330
+ .statusBarItemError {
331
+ color: var(--fui-color-danger, $fui-color-danger);
332
+ font-weight: var(--fui-font-weight-medium, $fui-font-weight-medium);
333
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen, userEvent, expectNoA11yViolations } from '../../test/utils';
3
+ import { Editor } from './index';
4
+ import { Button } from '../Button';
5
+
6
+ function renderEditor(
7
+ props: {
8
+ placeholder?: string;
9
+ disabled?: boolean;
10
+ readOnly?: boolean;
11
+ defaultValue?: string;
12
+ onValueChange?: (v: string) => void;
13
+ formats?: ('bold' | 'italic' | 'strikethrough' | 'link' | 'code' | 'bulletList')[];
14
+ } = {},
15
+ ) {
16
+ return render(
17
+ <Editor
18
+ placeholder={props.placeholder ?? 'Start typing your masterpiece here...'}
19
+ onValueChange={props.onValueChange}
20
+ disabled={props.disabled}
21
+ readOnly={props.readOnly}
22
+ defaultValue={props.defaultValue}
23
+ formats={props.formats}
24
+ >
25
+ <Editor.Toolbar>
26
+ <Editor.ToolbarGroup aria-label="Text formatting">
27
+ {(props.formats ?? ['bold', 'italic', 'code']).map((f) => (
28
+ <Editor.ToolbarButton key={f} format={f} />
29
+ ))}
30
+ </Editor.ToolbarGroup>
31
+ <Editor.ToolbarGroup aria-label="Actions">
32
+ <Editor.StatusIndicator status="saved" />
33
+ <Button variant="accent" size="sm">
34
+ Publish
35
+ </Button>
36
+ </Editor.ToolbarGroup>
37
+ </Editor.Toolbar>
38
+ <Editor.Content />
39
+ <Editor.StatusBar showWordCount showCharCount />
40
+ </Editor>,
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Find the editor content area. TipTap renders a contenteditable div,
46
+ * the fallback renders a textarea. Both are accessible as textbox role.
47
+ */
48
+ function getEditorInput() {
49
+ // Try textarea first (markdown fallback), then contenteditable (TipTap)
50
+ const textarea = document.querySelector('textarea');
51
+ if (textarea) return textarea;
52
+ const contenteditable = document.querySelector('[contenteditable]');
53
+ if (contenteditable) return contenteditable as HTMLElement;
54
+ throw new Error('Could not find editor content area');
55
+ }
56
+
57
+ describe('Editor', () => {
58
+ it('renders editor content area with placeholder', () => {
59
+ renderEditor({ placeholder: 'Write here...' });
60
+ // TipTap stores placeholder on wrapper via data-placeholder;
61
+ // textarea uses native placeholder. Check either.
62
+ const wrapper = document.querySelector('[data-placeholder="Write here..."]');
63
+ const textarea = document.querySelector('textarea[placeholder="Write here..."]');
64
+ expect(wrapper || textarea).toBeTruthy();
65
+ });
66
+
67
+ it('renders toolbar with format buttons', () => {
68
+ renderEditor({ formats: ['bold', 'italic', 'code'] });
69
+ expect(screen.getByRole('button', { name: /bold/i })).toBeInTheDocument();
70
+ expect(screen.getByRole('button', { name: /italic/i })).toBeInTheDocument();
71
+ expect(screen.getByRole('button', { name: /code/i })).toBeInTheDocument();
72
+ });
73
+
74
+ it('renders toolbar with proper ARIA role', () => {
75
+ renderEditor();
76
+ expect(screen.getByRole('toolbar')).toBeInTheDocument();
77
+ });
78
+
79
+ it('renders toolbar groups with role="group"', () => {
80
+ renderEditor();
81
+ const groups = screen.getAllByRole('group');
82
+ expect(groups.length).toBeGreaterThanOrEqual(2);
83
+ expect(groups[0]).toHaveAttribute('aria-label', 'Text formatting');
84
+ expect(groups[1]).toHaveAttribute('aria-label', 'Actions');
85
+ });
86
+
87
+ it('renders status indicator with AUTO-SAVED text', () => {
88
+ renderEditor();
89
+ expect(screen.getByText('AUTO-SAVED')).toBeInTheDocument();
90
+ });
91
+
92
+ it('renders status indicator with aria-live="polite"', () => {
93
+ renderEditor();
94
+ const indicator = screen.getByRole('status');
95
+ expect(indicator).toHaveAttribute('aria-live', 'polite');
96
+ });
97
+
98
+ it('renders Publish button', () => {
99
+ renderEditor();
100
+ expect(screen.getByRole('button', { name: /publish/i })).toBeInTheDocument();
101
+ });
102
+
103
+ it('renders status bar with word and character counts', () => {
104
+ renderEditor({ defaultValue: 'Hello world' });
105
+ expect(screen.getByText('2 Words')).toBeInTheDocument();
106
+ expect(screen.getByText('11 Characters')).toBeInTheDocument();
107
+ });
108
+
109
+ it('shows singular "Word" for count of 1', () => {
110
+ renderEditor({ defaultValue: 'Hello' });
111
+ expect(screen.getByText('1 Word')).toBeInTheDocument();
112
+ });
113
+
114
+ it('shows 0 Words when empty', () => {
115
+ renderEditor({ defaultValue: '' });
116
+ expect(screen.getByText('0 Words')).toBeInTheDocument();
117
+ });
118
+
119
+ it('toolbar buttons have aria-pressed attribute', () => {
120
+ renderEditor({ formats: ['bold'] });
121
+ const boldBtn = screen.getByRole('button', { name: /bold/i });
122
+ expect(boldBtn).toHaveAttribute('aria-pressed', 'false');
123
+ });
124
+
125
+ it('disables toolbar buttons when editor is disabled', () => {
126
+ renderEditor({ disabled: true, formats: ['bold', 'italic'] });
127
+ expect(screen.getByRole('button', { name: /bold/i })).toBeDisabled();
128
+ expect(screen.getByRole('button', { name: /italic/i })).toBeDisabled();
129
+ });
130
+
131
+ it('sets data-disabled on root when disabled', () => {
132
+ const { container } = renderEditor({ disabled: true });
133
+ const root = container.firstElementChild;
134
+ expect(root).toHaveAttribute('data-disabled');
135
+ });
136
+
137
+ it('sets contenteditable to false or disables textarea when disabled', () => {
138
+ renderEditor({ disabled: true });
139
+ const input = getEditorInput();
140
+ if (input instanceof HTMLTextAreaElement) {
141
+ expect(input).toBeDisabled();
142
+ } else {
143
+ expect(input).toHaveAttribute('contenteditable', 'false');
144
+ }
145
+ });
146
+
147
+ it('sets data-readonly on root when readOnly', () => {
148
+ const { container } = renderEditor({ readOnly: true });
149
+ const root = container.firstElementChild;
150
+ expect(root).toHaveAttribute('data-readonly');
151
+ });
152
+
153
+ it('disables toolbar buttons when readOnly', () => {
154
+ renderEditor({ readOnly: true, formats: ['bold'] });
155
+ expect(screen.getByRole('button', { name: /bold/i })).toBeDisabled();
156
+ });
157
+
158
+ it('renders with default toolbar and status bar when no children', () => {
159
+ render(
160
+ <Editor placeholder="Auto layout" formats={['bold', 'italic']} />,
161
+ );
162
+ expect(screen.getByRole('toolbar')).toBeInTheDocument();
163
+ // Check placeholder via wrapper or native textarea
164
+ const wrapper = document.querySelector('[data-placeholder="Auto layout"]');
165
+ const textarea = document.querySelector('textarea[placeholder="Auto layout"]');
166
+ expect(wrapper || textarea).toBeTruthy();
167
+ expect(screen.getByText('0 Words')).toBeInTheDocument();
168
+ });
169
+
170
+ it('has no accessibility violations', async () => {
171
+ const { container } = renderEditor();
172
+ await expectNoA11yViolations(container);
173
+ });
174
+ });