@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.
Files changed (38) hide show
  1. package/dist/components/buttonv2/Buttonv2.component.d.ts.map +1 -1
  2. package/dist/components/buttonv2/Buttonv2.component.js +9 -5
  3. package/dist/components/buttonv2/CopyButton.component.d.ts +1 -1
  4. package/dist/components/buttonv2/CopyButton.component.d.ts.map +1 -1
  5. package/dist/components/buttonv2/CopyButton.component.js +25 -15
  6. package/dist/components/editor/Editor.component.d.ts +17 -0
  7. package/dist/components/editor/Editor.component.d.ts.map +1 -0
  8. package/dist/components/editor/Editor.component.js +118 -0
  9. package/dist/components/editor/editorTheme.d.ts +5 -0
  10. package/dist/components/editor/editorTheme.d.ts.map +1 -0
  11. package/dist/components/editor/editorTheme.js +115 -0
  12. package/dist/components/editor/index.d.ts +3 -0
  13. package/dist/components/editor/index.d.ts.map +1 -0
  14. package/dist/components/editor/index.js +1 -0
  15. package/dist/components/iconhelper/IconHelper.d.ts.map +1 -1
  16. package/dist/components/iconhelper/IconHelper.js +0 -1
  17. package/dist/components/navbar/Navbar.component.d.ts.map +1 -1
  18. package/dist/components/navbar/Navbar.component.js +15 -10
  19. package/dist/next.d.ts +2 -0
  20. package/dist/next.d.ts.map +1 -1
  21. package/dist/next.js +1 -0
  22. package/dist/utils.d.ts +1 -0
  23. package/dist/utils.d.ts.map +1 -1
  24. package/dist/utils.js +24 -0
  25. package/package.json +7 -1
  26. package/src/lib/components/buttonv2/Buttonv2.component.tsx +8 -5
  27. package/src/lib/components/buttonv2/CopyButton.component.test.tsx +252 -0
  28. package/src/lib/components/buttonv2/CopyButton.component.tsx +28 -21
  29. package/src/lib/components/editor/Editor.component.tsx +163 -0
  30. package/src/lib/components/editor/Editor.test.tsx +295 -0
  31. package/src/lib/components/editor/editorTheme.ts +126 -0
  32. package/src/lib/components/editor/index.ts +2 -0
  33. package/src/lib/components/iconhelper/IconHelper.tsx +0 -1
  34. package/src/lib/components/navbar/Navbar.component.tsx +15 -10
  35. package/src/lib/next.ts +2 -0
  36. package/src/lib/utils.test.ts +48 -0
  37. package/src/lib/utils.ts +32 -0
  38. 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
+ }
@@ -0,0 +1,2 @@
1
+ export { Editor } from './Editor.component';
2
+ export type { EditorProps } from './Editor.component';
@@ -23,7 +23,6 @@ const HelpButton = styled.button`
23
23
  border: none;
24
24
  padding: 0;
25
25
  margin: 0;
26
- cursor: pointer;
27
26
  color: inherit;
28
27
  font: inherit; /* Inherit font sizing */
29
28
  vertical-align: text-bottom; /* Align with text */
@@ -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: ${getThemePropSelector('textPrimary')};
50
+ color: ${getNavbarTextColor};
48
51
  .fas,
49
52
  .sc-trigger-text {
50
- color: ${getThemePropSelector('textPrimary')};
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: ${getThemePropSelector('textPrimary')};
84
+ color: ${navTextColor};
81
85
  &:hover {
82
86
  background-color: ${getThemePropSelector('highlight')};
83
87
  }
84
88
  &.selected {
85
- color: ${getThemePropSelector('textPrimary')};
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: ${props.theme.textPrimary};
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 { textPrimary } = props.theme;
110
+ const navTextColor = getContrastText(props.theme.navbarBackground, props.theme.textPrimary, props.theme.textReverse) ?? props.theme.textPrimary;
107
111
  return css`
108
- color: ${textPrimary};
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: ${props.theme.textPrimary};
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 &&