@scality/core-ui 0.199.0 → 0.201.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/buttonv2/Buttonv2.component.d.ts.map +1 -1
- package/dist/components/buttonv2/Buttonv2.component.js +9 -5
- package/dist/components/buttonv2/CopyButton.component.d.ts +1 -1
- package/dist/components/buttonv2/CopyButton.component.d.ts.map +1 -1
- package/dist/components/buttonv2/CopyButton.component.js +25 -15
- package/dist/components/editor/Editor.component.d.ts +17 -0
- package/dist/components/editor/Editor.component.d.ts.map +1 -0
- package/dist/components/editor/Editor.component.js +118 -0
- package/dist/components/editor/editorTheme.d.ts +5 -0
- package/dist/components/editor/editorTheme.d.ts.map +1 -0
- package/dist/components/editor/editorTheme.js +115 -0
- package/dist/components/editor/index.d.ts +3 -0
- package/dist/components/editor/index.d.ts.map +1 -0
- package/dist/components/editor/index.js +1 -0
- package/dist/components/iconhelper/IconHelper.d.ts.map +1 -1
- package/dist/components/iconhelper/IconHelper.js +0 -1
- package/dist/components/navbar/Navbar.component.d.ts.map +1 -1
- package/dist/components/navbar/Navbar.component.js +15 -10
- package/dist/next.d.ts +2 -0
- package/dist/next.d.ts.map +1 -1
- package/dist/next.js +1 -0
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +24 -0
- package/package.json +7 -1
- package/src/lib/components/buttonv2/Buttonv2.component.tsx +8 -5
- package/src/lib/components/buttonv2/CopyButton.component.test.tsx +252 -0
- package/src/lib/components/buttonv2/CopyButton.component.tsx +28 -21
- package/src/lib/components/editor/Editor.component.tsx +163 -0
- package/src/lib/components/editor/Editor.test.tsx +295 -0
- package/src/lib/components/editor/editorTheme.ts +126 -0
- package/src/lib/components/editor/index.ts +2 -0
- package/src/lib/components/iconhelper/IconHelper.tsx +0 -1
- package/src/lib/components/navbar/Navbar.component.tsx +15 -10
- package/src/lib/next.ts +2 -0
- package/src/lib/utils.test.ts +48 -0
- package/src/lib/utils.ts +32 -0
- package/stories/editor.stories.tsx +132 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { ThemeProvider } from 'styled-components';
|
|
3
|
+
import { EditorView } from '@codemirror/view';
|
|
4
|
+
import { EditorState } from '@codemirror/state';
|
|
5
|
+
import { coreUIAvailableThemes } from '../../style/theme';
|
|
6
|
+
import { createEditorTheme, isDarkBackground } from './editorTheme';
|
|
7
|
+
import {
|
|
8
|
+
Editor,
|
|
9
|
+
isEditAttempt,
|
|
10
|
+
createReadOnlyTooltipExtension,
|
|
11
|
+
} from './Editor.component';
|
|
12
|
+
import React from "react";
|
|
13
|
+
|
|
14
|
+
const mockJsonSchema = jest.fn(() => []);
|
|
15
|
+
|
|
16
|
+
jest.mock('@uiw/react-codemirror', () => {
|
|
17
|
+
const MockCodeMirror = (props: {
|
|
18
|
+
value?: string;
|
|
19
|
+
readOnly?: boolean;
|
|
20
|
+
height?: string;
|
|
21
|
+
width?: string;
|
|
22
|
+
onChange?: (value: string) => void;
|
|
23
|
+
}) => (
|
|
24
|
+
<textarea
|
|
25
|
+
data-testid="codemirror-editor"
|
|
26
|
+
value={props.value}
|
|
27
|
+
readOnly={props.readOnly}
|
|
28
|
+
style={{ height: props.height, width: props.width }}
|
|
29
|
+
onChange={(e) => props.onChange?.(e.target.value)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
MockCodeMirror.displayName = 'MockCodeMirror';
|
|
33
|
+
return { __esModule: true, default: MockCodeMirror };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
jest.mock('codemirror-json-schema', () => ({
|
|
37
|
+
jsonSchema: (...args: unknown[]) => (mockJsonSchema as (...a: unknown[]) => unknown).apply(null, args),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const darkTheme = coreUIAvailableThemes.darkRebrand;
|
|
41
|
+
const lightTheme = coreUIAvailableThemes.artescaLight;
|
|
42
|
+
|
|
43
|
+
const renderWithTheme = (ui: React.ReactElement, theme = darkTheme) =>
|
|
44
|
+
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
mockJsonSchema.mockClear();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('isDarkBackground', () => {
|
|
51
|
+
it('returns true for dark themes', () => {
|
|
52
|
+
expect(isDarkBackground(darkTheme)).toBe(true);
|
|
53
|
+
expect(isDarkBackground(coreUIAvailableThemes.ring9dark)).toBe(true);
|
|
54
|
+
expect(isDarkBackground(coreUIAvailableThemes['G-Dark'])).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns false for light themes', () => {
|
|
58
|
+
expect(isDarkBackground(lightTheme)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('Editor', () => {
|
|
63
|
+
it('renders with dark theme', () => {
|
|
64
|
+
renderWithTheme(<Editor value='{"key": "value"}' />, darkTheme);
|
|
65
|
+
expect(screen.getByTestId('codemirror-editor')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('renders with light theme', () => {
|
|
69
|
+
renderWithTheme(<Editor value='{"key": "value"}' />, lightTheme);
|
|
70
|
+
expect(screen.getByTestId('codemirror-editor')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders in read-only mode', () => {
|
|
74
|
+
renderWithTheme(<Editor value='{"key": "value"}' readOnly />);
|
|
75
|
+
expect(screen.getByTestId('codemirror-editor')).toHaveAttribute('readonly');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('displays the provided value', () => {
|
|
79
|
+
const json = '{"hello": "world"}';
|
|
80
|
+
renderWithTheme(<Editor value={json} />);
|
|
81
|
+
expect(screen.getByTestId('codemirror-editor')).toHaveValue(json);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('renders with custom dimensions', () => {
|
|
85
|
+
renderWithTheme(<Editor value="" height="200px" width="500px" />);
|
|
86
|
+
const editor = screen.getByTestId('codemirror-editor');
|
|
87
|
+
expect(editor).toHaveStyle({ height: '200px', width: '500px' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('accepts language string shorthand', () => {
|
|
91
|
+
renderWithTheme(<Editor value='{"key": "value"}' language="json" />);
|
|
92
|
+
expect(screen.getByTestId('codemirror-editor')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('calls jsonSchema extension when schema is provided', () => {
|
|
96
|
+
const schema = {
|
|
97
|
+
type: 'object' as const,
|
|
98
|
+
properties: { name: { type: 'string' as const } },
|
|
99
|
+
};
|
|
100
|
+
renderWithTheme(
|
|
101
|
+
<Editor value='{"name": "test"}' language={{ name: 'json', schema }} />,
|
|
102
|
+
);
|
|
103
|
+
expect(mockJsonSchema).toHaveBeenCalledWith(schema);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does not call jsonSchema extension without schema', () => {
|
|
107
|
+
renderWithTheme(<Editor value='{}' language="json" />);
|
|
108
|
+
expect(mockJsonSchema).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('triggers onChange callback', () => {
|
|
112
|
+
const handleChange = jest.fn();
|
|
113
|
+
renderWithTheme(<Editor value='{"a": 1}' onChange={handleChange} />);
|
|
114
|
+
fireEvent.change(screen.getByTestId('codemirror-editor'), {
|
|
115
|
+
target: { value: '{"a": 2}' },
|
|
116
|
+
});
|
|
117
|
+
expect(handleChange).toHaveBeenCalledWith('{"a": 2}');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('isEditAttempt', () => {
|
|
122
|
+
const makeEvent = (overrides: Partial<KeyboardEvent> = {}): KeyboardEvent =>
|
|
123
|
+
({
|
|
124
|
+
ctrlKey: false,
|
|
125
|
+
metaKey: false,
|
|
126
|
+
altKey: false,
|
|
127
|
+
...overrides,
|
|
128
|
+
}) as unknown as KeyboardEvent;
|
|
129
|
+
|
|
130
|
+
it('detects regular character typing', () => {
|
|
131
|
+
expect(isEditAttempt(makeEvent({ key: 'a' }))).toBe(true);
|
|
132
|
+
expect(isEditAttempt(makeEvent({ key: '1' }))).toBe(true);
|
|
133
|
+
expect(isEditAttempt(makeEvent({ key: ' ' }))).toBe(true);
|
|
134
|
+
expect(isEditAttempt(makeEvent({ key: '{' }))).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('detects edit keys (Backspace, Delete, Enter, Tab)', () => {
|
|
138
|
+
expect(isEditAttempt(makeEvent({ key: 'Backspace' }))).toBe(true);
|
|
139
|
+
expect(isEditAttempt(makeEvent({ key: 'Delete' }))).toBe(true);
|
|
140
|
+
expect(isEditAttempt(makeEvent({ key: 'Enter' }))).toBe(true);
|
|
141
|
+
expect(isEditAttempt(makeEvent({ key: 'Tab' }))).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('detects cut and paste shortcuts', () => {
|
|
145
|
+
expect(isEditAttempt(makeEvent({ key: 'x', ctrlKey: true }))).toBe(true);
|
|
146
|
+
expect(isEditAttempt(makeEvent({ key: 'v', ctrlKey: true }))).toBe(true);
|
|
147
|
+
expect(isEditAttempt(makeEvent({ key: 'x', metaKey: true }))).toBe(true);
|
|
148
|
+
expect(isEditAttempt(makeEvent({ key: 'v', metaKey: true }))).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('ignores copy, select-all, and undo shortcuts', () => {
|
|
152
|
+
expect(isEditAttempt(makeEvent({ key: 'c', ctrlKey: true }))).toBe(false);
|
|
153
|
+
expect(isEditAttempt(makeEvent({ key: 'a', ctrlKey: true }))).toBe(false);
|
|
154
|
+
expect(isEditAttempt(makeEvent({ key: 'z', ctrlKey: true }))).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('ignores navigation and modifier keys', () => {
|
|
158
|
+
expect(isEditAttempt(makeEvent({ key: 'ArrowLeft' }))).toBe(false);
|
|
159
|
+
expect(isEditAttempt(makeEvent({ key: 'ArrowUp' }))).toBe(false);
|
|
160
|
+
expect(isEditAttempt(makeEvent({ key: 'Escape' }))).toBe(false);
|
|
161
|
+
expect(isEditAttempt(makeEvent({ key: 'Shift' }))).toBe(false);
|
|
162
|
+
expect(isEditAttempt(makeEvent({ key: 'Control' }))).toBe(false);
|
|
163
|
+
expect(isEditAttempt(makeEvent({ key: 'F1' }))).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('createEditorTheme', () => {
|
|
168
|
+
it('returns two extensions for dark theme', () => {
|
|
169
|
+
const result = createEditorTheme(darkTheme);
|
|
170
|
+
expect(Array.isArray(result)).toBe(true);
|
|
171
|
+
expect(result).toHaveLength(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('returns two extensions for light theme', () => {
|
|
175
|
+
const result = createEditorTheme(lightTheme);
|
|
176
|
+
expect(Array.isArray(result)).toBe(true);
|
|
177
|
+
expect(result).toHaveLength(2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('handles all available themes without errors', () => {
|
|
181
|
+
Object.values(coreUIAvailableThemes).forEach((theme) => {
|
|
182
|
+
expect(() => createEditorTheme(theme)).not.toThrow();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('createReadOnlyTooltipExtension', () => {
|
|
188
|
+
let parent: HTMLDivElement;
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
parent = document.createElement('div');
|
|
192
|
+
document.body.appendChild(parent);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
parent.remove();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('shows tooltip with ARIA attributes on edit attempt', () => {
|
|
200
|
+
const view = new EditorView({
|
|
201
|
+
state: EditorState.create({
|
|
202
|
+
doc: 'hello',
|
|
203
|
+
extensions: [createReadOnlyTooltipExtension()],
|
|
204
|
+
}),
|
|
205
|
+
parent,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
jest
|
|
209
|
+
.spyOn(view, 'coordsAtPos')
|
|
210
|
+
.mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
|
|
211
|
+
|
|
212
|
+
view.dom.dispatchEvent(
|
|
213
|
+
new KeyboardEvent('keydown', { key: 'a', bubbles: true }),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const tooltip = parent.querySelector('.cm-readonly-tooltip');
|
|
217
|
+
expect(tooltip).not.toBeNull();
|
|
218
|
+
expect(tooltip?.getAttribute('role')).toBe('status');
|
|
219
|
+
expect(tooltip?.getAttribute('aria-live')).toBe('polite');
|
|
220
|
+
expect(tooltip?.textContent).toBe('Cannot edit in read-only editor');
|
|
221
|
+
|
|
222
|
+
view.destroy();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('dismisses tooltip after 2 seconds', () => {
|
|
226
|
+
jest.useFakeTimers();
|
|
227
|
+
|
|
228
|
+
const view = new EditorView({
|
|
229
|
+
state: EditorState.create({
|
|
230
|
+
doc: 'hello',
|
|
231
|
+
extensions: [createReadOnlyTooltipExtension()],
|
|
232
|
+
}),
|
|
233
|
+
parent,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
jest
|
|
237
|
+
.spyOn(view, 'coordsAtPos')
|
|
238
|
+
.mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
|
|
239
|
+
|
|
240
|
+
view.dom.dispatchEvent(
|
|
241
|
+
new KeyboardEvent('keydown', { key: 'a', bubbles: true }),
|
|
242
|
+
);
|
|
243
|
+
expect(parent.querySelector('.cm-readonly-tooltip')).not.toBeNull();
|
|
244
|
+
|
|
245
|
+
jest.advanceTimersByTime(2000);
|
|
246
|
+
expect(parent.querySelector('.cm-readonly-tooltip')).toBeNull();
|
|
247
|
+
|
|
248
|
+
view.destroy();
|
|
249
|
+
jest.useRealTimers();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('cleans up DOM elements on destroy', () => {
|
|
253
|
+
const view = new EditorView({
|
|
254
|
+
state: EditorState.create({
|
|
255
|
+
doc: 'hello',
|
|
256
|
+
extensions: [createReadOnlyTooltipExtension()],
|
|
257
|
+
}),
|
|
258
|
+
parent,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
jest
|
|
262
|
+
.spyOn(view, 'coordsAtPos')
|
|
263
|
+
.mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
|
|
264
|
+
|
|
265
|
+
view.dom.dispatchEvent(
|
|
266
|
+
new KeyboardEvent('keydown', { key: 'a', bubbles: true }),
|
|
267
|
+
);
|
|
268
|
+
expect(parent.querySelector('.cm-readonly-tooltip')).not.toBeNull();
|
|
269
|
+
|
|
270
|
+
view.destroy();
|
|
271
|
+
expect(parent.querySelector('.cm-readonly-tooltip')).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('does not show tooltip for navigation keys', () => {
|
|
275
|
+
const view = new EditorView({
|
|
276
|
+
state: EditorState.create({
|
|
277
|
+
doc: 'hello',
|
|
278
|
+
extensions: [createReadOnlyTooltipExtension()],
|
|
279
|
+
}),
|
|
280
|
+
parent,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
jest
|
|
284
|
+
.spyOn(view, 'coordsAtPos')
|
|
285
|
+
.mockReturnValue({ top: 10, bottom: 20, left: 30, right: 40 });
|
|
286
|
+
|
|
287
|
+
view.dom.dispatchEvent(
|
|
288
|
+
new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expect(parent.querySelector('.cm-readonly-tooltip')).toBeNull();
|
|
292
|
+
|
|
293
|
+
view.destroy();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { EditorView } from '@codemirror/view';
|
|
2
|
+
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
3
|
+
import { tags } from '@lezer/highlight';
|
|
4
|
+
import { getLuminance } from 'polished';
|
|
5
|
+
import type { Extension } from '@codemirror/state';
|
|
6
|
+
import type { CoreUITheme } from '../../style/theme';
|
|
7
|
+
import { lineColor5 } from '../../style/theme';
|
|
8
|
+
|
|
9
|
+
export function isDarkBackground(theme: CoreUITheme): boolean {
|
|
10
|
+
try {
|
|
11
|
+
return getLuminance(theme.backgroundLevel1) < 0.5;
|
|
12
|
+
} catch {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getSyntaxColors(theme: CoreUITheme, isDark: boolean) {
|
|
18
|
+
if (isDark) {
|
|
19
|
+
return {
|
|
20
|
+
property: theme.statusHealthy,
|
|
21
|
+
string: lineColor5,
|
|
22
|
+
number: lineColor5,
|
|
23
|
+
boolean: lineColor5,
|
|
24
|
+
null: theme.textSecondary,
|
|
25
|
+
bracket: theme.textPrimary,
|
|
26
|
+
punctuation: theme.textPrimary,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
property: '#4078F2',
|
|
31
|
+
string: '#50A14F',
|
|
32
|
+
number: '#986801',
|
|
33
|
+
boolean: '#0184BC',
|
|
34
|
+
null: '#0184BC',
|
|
35
|
+
bracket: '#383A42',
|
|
36
|
+
punctuation: '#383A42',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createEditorTheme(theme: CoreUITheme): Extension {
|
|
41
|
+
const isDark = isDarkBackground(theme);
|
|
42
|
+
const syntax = getSyntaxColors(theme, isDark);
|
|
43
|
+
|
|
44
|
+
const editorViewTheme = EditorView.theme(
|
|
45
|
+
{
|
|
46
|
+
'&': {
|
|
47
|
+
backgroundColor: theme.backgroundLevel1,
|
|
48
|
+
color: theme.textPrimary,
|
|
49
|
+
},
|
|
50
|
+
'.cm-content': {
|
|
51
|
+
caretColor: theme.textPrimary,
|
|
52
|
+
fontFamily: "'Courier New', monospace",
|
|
53
|
+
fontSize: '12px',
|
|
54
|
+
},
|
|
55
|
+
'.cm-cursor, .cm-dropCursor': {
|
|
56
|
+
borderLeftColor: theme.textPrimary,
|
|
57
|
+
},
|
|
58
|
+
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
|
59
|
+
{
|
|
60
|
+
backgroundColor: theme.highlight,
|
|
61
|
+
},
|
|
62
|
+
'.cm-gutters': {
|
|
63
|
+
backgroundColor: theme.backgroundLevel1,
|
|
64
|
+
color: theme.textSecondary,
|
|
65
|
+
border: 'none',
|
|
66
|
+
borderRight: `1px solid ${theme.border}`,
|
|
67
|
+
},
|
|
68
|
+
'.cm-activeLineGutter': {
|
|
69
|
+
backgroundColor: theme.backgroundLevel3,
|
|
70
|
+
},
|
|
71
|
+
'.cm-activeLine': {
|
|
72
|
+
backgroundColor: theme.backgroundLevel3,
|
|
73
|
+
},
|
|
74
|
+
'.cm-foldPlaceholder': {
|
|
75
|
+
backgroundColor: 'transparent',
|
|
76
|
+
border: 'none',
|
|
77
|
+
color: theme.textSecondary,
|
|
78
|
+
},
|
|
79
|
+
'.cm-diagnostic-error': {
|
|
80
|
+
borderLeftColor: theme.statusCritical,
|
|
81
|
+
},
|
|
82
|
+
'.cm-diagnostic-warning': {
|
|
83
|
+
borderLeftColor: theme.statusWarning,
|
|
84
|
+
},
|
|
85
|
+
'.cm-lintRange-error': {
|
|
86
|
+
backgroundImage: 'none',
|
|
87
|
+
textDecoration: `underline wavy ${theme.statusCritical}`,
|
|
88
|
+
},
|
|
89
|
+
'.cm-lintRange-warning': {
|
|
90
|
+
backgroundImage: 'none',
|
|
91
|
+
textDecoration: `underline wavy ${theme.statusWarning}`,
|
|
92
|
+
},
|
|
93
|
+
'.cm-tooltip': {
|
|
94
|
+
backgroundColor: theme.backgroundLevel3,
|
|
95
|
+
color: theme.textPrimary,
|
|
96
|
+
border: `1px solid ${theme.border}`,
|
|
97
|
+
},
|
|
98
|
+
'.cm-tooltip-lint': {
|
|
99
|
+
backgroundColor: theme.backgroundLevel3,
|
|
100
|
+
},
|
|
101
|
+
'.cm-panels': {
|
|
102
|
+
backgroundColor: theme.backgroundLevel4,
|
|
103
|
+
color: theme.textPrimary,
|
|
104
|
+
},
|
|
105
|
+
'.cm-readonly-tooltip': {
|
|
106
|
+
backgroundColor: theme.backgroundLevel3,
|
|
107
|
+
color: theme.textPrimary,
|
|
108
|
+
border: `1px solid ${theme.border}`,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{ dark: isDark },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const highlightStyle = HighlightStyle.define([
|
|
115
|
+
{ tag: tags.propertyName, color: syntax.property },
|
|
116
|
+
{ tag: tags.string, color: syntax.string },
|
|
117
|
+
{ tag: tags.number, color: syntax.number },
|
|
118
|
+
{ tag: tags.bool, color: syntax.boolean },
|
|
119
|
+
{ tag: tags.null, color: syntax.null },
|
|
120
|
+
{ tag: tags.punctuation, color: syntax.punctuation },
|
|
121
|
+
{ tag: tags.brace, color: syntax.bracket },
|
|
122
|
+
{ tag: tags.squareBracket, color: syntax.bracket },
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
return [editorViewTheme, syntaxHighlighting(highlightStyle)];
|
|
126
|
+
}
|
|
@@ -3,7 +3,7 @@ import styled, { css } from 'styled-components';
|
|
|
3
3
|
import { Logo } from '../../icons/branding';
|
|
4
4
|
import { spacing } from '../../spacing';
|
|
5
5
|
import { fontSize, navbarHeight, navbarItemWidth } from '../../style/theme';
|
|
6
|
-
import { getThemePropSelector } from '../../utils';
|
|
6
|
+
import { getContrastText, getThemePropSelector } from '../../utils';
|
|
7
7
|
import { Dropdown, type Item } from '../dropdown/Dropdown.component';
|
|
8
8
|
import { Icon } from '../icon/Icon.component';
|
|
9
9
|
import { Button, FocusVisibleStyle, type Props as ButtonProps } from '../buttonv2/Buttonv2.component';
|
|
@@ -38,16 +38,19 @@ export type Props = {
|
|
|
38
38
|
logo?: JSX.Element;
|
|
39
39
|
tabs?: Array<Tab>;
|
|
40
40
|
};
|
|
41
|
+
const getNavbarTextColor = (props) =>
|
|
42
|
+
getContrastText(props.theme.navbarBackground, props.theme.textPrimary, props.theme.textReverse) ?? props.theme.textPrimary;
|
|
43
|
+
|
|
41
44
|
const NavbarContainer = styled.div`
|
|
42
45
|
height: ${navbarHeight};
|
|
43
46
|
display: flex;
|
|
44
47
|
justify-content: space-between;
|
|
45
48
|
${css`
|
|
46
49
|
background-color: ${getThemePropSelector('navbarBackground')};
|
|
47
|
-
color: ${
|
|
50
|
+
color: ${getNavbarTextColor};
|
|
48
51
|
.fas,
|
|
49
52
|
.sc-trigger-text {
|
|
50
|
-
color: ${
|
|
53
|
+
color: ${getNavbarTextColor};
|
|
51
54
|
}
|
|
52
55
|
box-sizing: border-box;
|
|
53
56
|
border-bottom: 0.5px solid ${(props) => props.theme.backgroundLevel2};
|
|
@@ -76,20 +79,21 @@ const NavbarTabs = styled.div`
|
|
|
76
79
|
border-top: ${spacing.r2} solid transparent;
|
|
77
80
|
${(props) => {
|
|
78
81
|
const { selectedActive } = props.theme;
|
|
82
|
+
const navTextColor = getContrastText(props.theme.navbarBackground, props.theme.textPrimary, props.theme.textReverse) ?? props.theme.textPrimary;
|
|
79
83
|
return css`
|
|
80
|
-
color: ${
|
|
84
|
+
color: ${navTextColor};
|
|
81
85
|
&:hover {
|
|
82
86
|
background-color: ${getThemePropSelector('highlight')};
|
|
83
87
|
}
|
|
84
88
|
&.selected {
|
|
85
|
-
color: ${
|
|
89
|
+
color: ${navTextColor};
|
|
86
90
|
font-weight: bold;
|
|
87
91
|
border-bottom-color: ${selectedActive};
|
|
88
92
|
}
|
|
89
93
|
// :focus-visible is the keyboard-only version of :focus
|
|
90
94
|
&:focus-visible {
|
|
91
95
|
${FocusVisibleStyle}
|
|
92
|
-
color: ${
|
|
96
|
+
color: ${navTextColor};
|
|
93
97
|
}
|
|
94
98
|
`;
|
|
95
99
|
}};
|
|
@@ -103,9 +107,9 @@ const TabItem = styled.div<{ selected: boolean }>`
|
|
|
103
107
|
align-items: center;
|
|
104
108
|
padding: 0 ${spacing.r16};
|
|
105
109
|
${(props) => {
|
|
106
|
-
const
|
|
110
|
+
const navTextColor = getContrastText(props.theme.navbarBackground, props.theme.textPrimary, props.theme.textReverse) ?? props.theme.textPrimary;
|
|
107
111
|
return css`
|
|
108
|
-
color: ${
|
|
112
|
+
color: ${navTextColor};
|
|
109
113
|
&:hover {
|
|
110
114
|
border-bottom: ${spacing.r2} solid;
|
|
111
115
|
border-top: ${spacing.r2} solid;
|
|
@@ -114,7 +118,7 @@ const TabItem = styled.div<{ selected: boolean }>`
|
|
|
114
118
|
// :focus-visible is the keyboard-only version of :focus
|
|
115
119
|
&:focus-visible {
|
|
116
120
|
${FocusVisibleStyle}
|
|
117
|
-
color: ${
|
|
121
|
+
color: ${navTextColor};
|
|
118
122
|
}
|
|
119
123
|
`;
|
|
120
124
|
}};
|
|
@@ -149,13 +153,14 @@ const NavbarMenuItem = styled.div`
|
|
|
149
153
|
height: ${navbarHeight};
|
|
150
154
|
font-size: ${fontSize.base};
|
|
151
155
|
background-color: ${getThemePropSelector('navbarBackground')};
|
|
156
|
+
color: ${(props) => getContrastText(props.theme.navbarBackground, props.theme.textPrimary, props.theme.textReverse) ?? props.theme.textPrimary};
|
|
152
157
|
&:hover {
|
|
153
158
|
background-color: ${getThemePropSelector('highlight')};
|
|
154
159
|
}
|
|
155
160
|
// :focus-visible is the keyboard-only version of :focus
|
|
156
161
|
&:focus-visible {
|
|
157
162
|
${FocusVisibleStyle}
|
|
158
|
-
color: ${(props) => props.theme.textPrimary};
|
|
163
|
+
color: ${(props) => getContrastText(props.theme.navbarBackground, props.theme.textPrimary, props.theme.textReverse) ?? props.theme.textPrimary};
|
|
159
164
|
}
|
|
160
165
|
width: ${navbarItemWidth};
|
|
161
166
|
}
|
package/src/lib/next.ts
CHANGED
|
@@ -17,6 +17,8 @@ export { CoreUiThemeProvider } from './components/coreuithemeprovider/CoreUiThem
|
|
|
17
17
|
export { Box } from './components/box/Box';
|
|
18
18
|
export { Input } from './components/inputv2/inputv2';
|
|
19
19
|
export { Accordion } from './components/accordion/Accordion.component';
|
|
20
|
+
export { Editor } from './components/editor';
|
|
21
|
+
export type { EditorProps } from './components/editor';
|
|
20
22
|
|
|
21
23
|
// Export all chart components from the consolidated charts folder
|
|
22
24
|
export {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getContrastText } from './utils';
|
|
2
|
+
|
|
3
|
+
const LIGHT_TEXT = '#EAEAEA';
|
|
4
|
+
const DARK_TEXT = '#000000';
|
|
5
|
+
|
|
6
|
+
describe('getContrastText', () => {
|
|
7
|
+
it('returns textPrimary on dark backgrounds when textPrimary is light', () => {
|
|
8
|
+
expect(getContrastText('#000000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
9
|
+
expect(getContrastText('#1A1A1A', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
10
|
+
expect(getContrastText('#121219', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
11
|
+
expect(getContrastText('#2F4185', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns textReverse on light backgrounds when textPrimary is light', () => {
|
|
15
|
+
expect(getContrastText('#FFFFFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
16
|
+
expect(getContrastText('#F5F5F5', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
17
|
+
expect(getContrastText('#FCFCFC', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('picks the text color with better contrast against a vivid background', () => {
|
|
21
|
+
expect(getContrastText('#E9041E', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
22
|
+
expect(getContrastText('#E9041E', '#FFFFFF', '#000000')).toBe('#FFFFFF');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('handles 3-character hex shorthand', () => {
|
|
26
|
+
expect(getContrastText('#FFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
27
|
+
expect(getContrastText('#000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles hex without # prefix', () => {
|
|
31
|
+
expect(getContrastText('000000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
|
|
32
|
+
expect(getContrastText('FFFFFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null for non-hex values', () => {
|
|
36
|
+
expect(
|
|
37
|
+
getContrastText(
|
|
38
|
+
'linear-gradient(130deg, #9355E7 0%, #2E4AA3 60%)',
|
|
39
|
+
LIGHT_TEXT,
|
|
40
|
+
DARK_TEXT,
|
|
41
|
+
),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
expect(getContrastText('not-a-color', LIGHT_TEXT, DARK_TEXT)).toBeNull();
|
|
44
|
+
expect(
|
|
45
|
+
getContrastText('rgb(255, 0, 0)', LIGHT_TEXT, DARK_TEXT),
|
|
46
|
+
).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/lib/utils.ts
CHANGED
|
@@ -45,6 +45,38 @@ export const hex2RGB = (str: string): [number, number, number] => {
|
|
|
45
45
|
throw new Error('Invalid hex string provided');
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
// WCAG 2.0 relative luminance
|
|
49
|
+
const relativeLuminance = (r: number, g: number, b: number): number => {
|
|
50
|
+
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
51
|
+
const s = c / 255;
|
|
52
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
53
|
+
});
|
|
54
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const wcagContrastRatio = (l1: number, l2: number): number =>
|
|
58
|
+
(Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
59
|
+
|
|
60
|
+
const luminanceOf = (hex: string): number => {
|
|
61
|
+
const [r, g, b] = hex2RGB(hex);
|
|
62
|
+
return relativeLuminance(r, g, b);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const getContrastText = (
|
|
66
|
+
bgColor: string,
|
|
67
|
+
textPrimary: string,
|
|
68
|
+
textReverse: string,
|
|
69
|
+
): string | null => {
|
|
70
|
+
try {
|
|
71
|
+
const bgLum = luminanceOf(bgColor);
|
|
72
|
+
const primaryContrast = wcagContrastRatio(luminanceOf(textPrimary), bgLum);
|
|
73
|
+
const reverseContrast = wcagContrastRatio(luminanceOf(textReverse), bgLum);
|
|
74
|
+
return reverseContrast > primaryContrast ? textReverse : textPrimary;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
48
80
|
export const convertRemToPixels = (rem: number): number => {
|
|
49
81
|
if (
|
|
50
82
|
document.documentElement &&
|