Skip to content

Commit

Permalink
Added support for colorScheme display option
Browse files Browse the repository at this point in the history
  • Loading branch information
salmenus committed May 16, 2024
1 parent 422e847 commit 44a9b0d
Show file tree
Hide file tree
Showing 10 changed files with 390 additions and 26 deletions.
11 changes: 11 additions & 0 deletions packages/js/core/src/exports/aiChat/options/displayOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ export type DisplayOptions = {
*/
themeId?: string;

/**
* Color scheme for the component.
* This can be 'light', 'dark', or 'auto'.
*
* If 'auto' is used, the component will automatically switch between 'light' and 'dark' based on the user's
* operating system preferences (if it can be detected), otherwise it will default to 'light'.
*
* @default 'auto'
*/
colorScheme?: 'light' | 'dark' | 'auto';

/**
* The width of the component.
*/
Expand Down
27 changes: 19 additions & 8 deletions packages/js/core/src/exports/aiChat/renderer/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export class NluxRenderer<AiMsg> {
return this.theClassName ?? undefined;
}

public get colorScheme(): 'light' | 'dark' | 'auto' | undefined {
return this.theDisplayOptions.colorScheme;
}

get context() {
return this.__context;
}
Expand Down Expand Up @@ -289,20 +293,26 @@ export class NluxRenderer<AiMsg> {
}

let themeIdUpdated = false;
let colorSchemeUpdated = false;
if (props.hasOwnProperty('displayOptions')) {
const newDisplayOptions: Partial<DisplayOptions> = {};

if (props.displayOptions?.hasOwnProperty('height')) {
newDisplayOptions.height = props.displayOptions.height;
if (props.displayOptions?.themeId !== this.theDisplayOptions.themeId) {
newDisplayOptions.themeId = props.displayOptions?.themeId;
themeIdUpdated = true;
}

if (props.displayOptions?.colorScheme !== this.theDisplayOptions.colorScheme) {
newDisplayOptions.colorScheme = props.displayOptions?.colorScheme;
colorSchemeUpdated = true;
}

if (props.displayOptions?.hasOwnProperty('width')) {
newDisplayOptions.width = props.displayOptions.width;
if (props.displayOptions?.height !== this.theDisplayOptions.height) {
newDisplayOptions.height = props.displayOptions?.height;
}

if (props.displayOptions?.themeId !== this.theDisplayOptions.themeId) {
newDisplayOptions.themeId = props.displayOptions?.themeId;
themeIdUpdated = true;
if (props.displayOptions?.width !== this.theDisplayOptions.width) {
newDisplayOptions.width = props.displayOptions?.width;
}

if (Object.keys(newDisplayOptions).length > 0) {
Expand All @@ -315,7 +325,7 @@ export class NluxRenderer<AiMsg> {
}
}

if (themeIdUpdated || classNameUpdated) {
if (themeIdUpdated || colorSchemeUpdated || classNameUpdated) {
this.setRootElementClassNames();
}

Expand Down Expand Up @@ -435,6 +445,7 @@ export class NluxRenderer<AiMsg> {
const rootClassNames = getRootClassNames({
themeId: this.themeId,
className: this.className,
colorScheme: this.colorScheme,
});

this.rootElement.className = '';
Expand Down
7 changes: 5 additions & 2 deletions packages/react/core/src/exports/AiChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const AiChat: <AiMsg>(
displayOptions,
} = props;

const themeId = displayOptions?.themeId;
const {themeId, colorScheme} = displayOptions ?? {};

// References to DOM elements and React components:
// These are use for advanced interactions such as scrolling, streaming, and
Expand Down Expand Up @@ -62,8 +62,11 @@ export const AiChat: <AiMsg>(

const hasValidInput = useMemo(() => prompt.length > 0, [prompt]);
const adapterToUse = useMemo(() => adapterParamToUsableAdapter<AiMsg>(adapter), [adapter]);
const rootClassNames = useMemo(() => getRootClassNames({className, themeId}).join(' '), [className, themeId]);
const rootStyle = useAiChatStyle(displayOptions);
const rootClassNames = useMemo(
() => getRootClassNames({className, themeId, colorScheme}).join(' '),
[className, themeId, colorScheme],
);

// Callbacks and handlers for user interactions and events
const showException = useCallback(
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/utils/dom/getRootClassNames.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
const rootClassName = 'nlux-AiChat-root';
const defaultThemeId = 'luna';

const getSystemColorScheme = () => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)')?.matches) {
return 'dark';
}

return 'light';
};

export const getRootClassNames = (props: {
themeId?: string;
className?: string;
colorScheme?: 'light' | 'dark' | 'auto';
}): string[] => {
const result = [rootClassName];
const themeId = props.themeId || defaultThemeId;
const themeClassName = `nlux-theme-${themeId}`;

const colorSchemaToUse = props.colorScheme || 'auto';
const colorSchemeToApply = colorSchemaToUse === 'auto' ? getSystemColorScheme() : colorSchemaToUse;
const colorSchemeClassName = `nlux-colorScheme-${colorSchemeToApply}`;

result.push(themeClassName);
result.push(colorSchemeClassName);

if (props.className) {
result.push(props.className);
}
Expand Down
1 change: 1 addition & 0 deletions samples/stock-wiz/src/StockWiz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const StockWiz = () => {
<Portfolio state={state} actions={actions}/>
<AiChat
className="aichat"
displayOptions={{colorScheme: 'dark'}}
adapter={nlBridgeChatAdapter}
/>
</div>
Expand Down
21 changes: 13 additions & 8 deletions specs/specs/aiChat/options/js/className.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ describe('createAiChat() + prop className', () => {
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.classList.length).toBe(2);
expect(aiChatDom.classList.length).toBe(3);
expect(aiChatDom.classList[0]).toBe('nlux-AiChat-root');
expect(aiChatDom.classList[1]).toBe('nlux-theme-luna');
expect(aiChatDom.classList[2]).toBe('nlux-colorScheme-light');
});

describe('When a className is set', () => {
Expand All @@ -51,10 +52,11 @@ describe('createAiChat() + prop className', () => {
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.classList.length).toBe(3);
expect(aiChatDom.classList.length).toBe(4);
expect(aiChatDom.classList[0]).toBe('nlux-AiChat-root');
expect(aiChatDom.classList[1]).toBe('nlux-theme-luna');
expect(aiChatDom.classList[2]).toBe('my-class');
expect(aiChatDom.classList[2]).toBe('nlux-colorScheme-light');
expect(aiChatDom.classList[3]).toBe('my-class');
});
});
});
Expand All @@ -70,10 +72,11 @@ describe('createAiChat() + prop className', () => {
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.classList.length).toBe(3);
expect(aiChatDom.classList.length).toBe(4);
expect(aiChatDom.classList[0]).toBe('nlux-AiChat-root');
expect(aiChatDom.classList[1]).toBe('nlux-theme-luna');
expect(aiChatDom.classList[2]).toBe('my-class');
expect(aiChatDom.classList[2]).toBe('nlux-colorScheme-light');
expect(aiChatDom.classList[3]).toBe('my-class');
});

describe('When a different className is set', () => {
Expand All @@ -89,10 +92,11 @@ describe('createAiChat() + prop className', () => {
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.classList.length).toBe(3);
expect(aiChatDom.classList.length).toBe(4);
expect(aiChatDom.classList[0]).toBe('nlux-AiChat-root');
expect(aiChatDom.classList[1]).toBe('nlux-theme-luna');
expect(aiChatDom.classList[2]).toBe('my-new-class');
expect(aiChatDom.classList[2]).toBe('nlux-colorScheme-light');
expect(aiChatDom.classList[3]).toBe('my-new-class');
});
});

Expand All @@ -109,9 +113,10 @@ describe('createAiChat() + prop className', () => {
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.classList.length).toBe(2);
expect(aiChatDom.classList.length).toBe(3);
expect(aiChatDom.classList[0]).toBe('nlux-AiChat-root');
expect(aiChatDom.classList[1]).toBe('nlux-theme-luna');
expect(aiChatDom.classList[2]).toBe('nlux-colorScheme-light');
});
});
});
Expand Down
187 changes: 187 additions & 0 deletions specs/specs/aiChat/options/js/displayOptions-colorScheme.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import {AiChat, createAiChat} from '@nlux-dev/core/src';
import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest';
import {adapterBuilder} from '../../../../utils/adapterBuilder';
import {AdapterController} from '../../../../utils/adapters';
import {waitForRenderCycle} from '../../../../utils/wait';

describe('createAiChat() + displayOptions + colorScheme', () => {
let adapterController: AdapterController | undefined;
let rootElement: HTMLElement;
let aiChat: AiChat | undefined;

let matchMedia: Mock;
let matchMediaNative: typeof window.matchMedia;

beforeEach(() => {
adapterController = adapterBuilder().withFetchText().create();
rootElement = document.createElement('div');
document.body.append(rootElement);
matchMedia = vi.fn(() => ({matches: true}));
matchMediaNative = window.matchMedia;
(window as unknown as Record<string, unknown>).matchMedia = matchMedia;
});

afterEach(() => {
adapterController = undefined;
aiChat?.unmount();
rootElement?.remove();
(window as unknown as Record<string, unknown>).matchMedia = matchMediaNative;
});

describe('When the component is created without a color scheme option', () => {
describe('When the system default color scheme is light', () => {
it('The system default light theme scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: false});
aiChat = createAiChat().withAdapter(adapterController!.adapter);

// Act
aiChat.mount(rootElement);
await waitForRenderCycle();
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-light');
});
});

describe('When the system default color scheme is dark', () => {
it('The system default dark theme scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: true});
aiChat = createAiChat().withAdapter(adapterController!.adapter);

// Act
aiChat.mount(rootElement);
await waitForRenderCycle();
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-dark');
});
});
});

describe('When a color scheme option is set to light', () => {
it('The light theme scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: true}); // System default is dark

// Act
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.withDisplayOptions({
colorScheme: 'light',
});

aiChat.mount(rootElement);
await waitForRenderCycle();

const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-light');
});

describe('When the color scheme is updated after the component is created', () => {
it('The new color scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: true}); // System default is dark
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.withDisplayOptions({
colorScheme: 'light',
});

aiChat.mount(rootElement);
await waitForRenderCycle();

// Act
aiChat.updateProps({
displayOptions: {
colorScheme: 'dark',
},
});
await waitForRenderCycle();
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-dark');
});
});
});

describe('When a color scheme option is unset after the component is created', () => {
it('The system default color scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: true}); // System default is dark
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.withDisplayOptions({
colorScheme: 'light',
});

aiChat.mount(rootElement);
await waitForRenderCycle();

// Act
aiChat.updateProps({
displayOptions: {
colorScheme: undefined,
},
});
await waitForRenderCycle();
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-dark');
});
});

describe('When the displayOptions are unset after the component is created', () => {
it('The system default color scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: true}); // System default is dark
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.withDisplayOptions({
colorScheme: 'light',
});

aiChat.mount(rootElement);
await waitForRenderCycle();

// Act
aiChat.updateProps({
displayOptions: undefined,
});
await waitForRenderCycle();
const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-dark');
});
});

describe('When a color scheme option is set to dark', () => {
it('The dark theme scheme should be used', async () => {
// Arrange
matchMedia.mockReturnValue({matches: false}); // System default is light

// Act
aiChat = createAiChat()
.withAdapter(adapterController!.adapter)
.withDisplayOptions({
colorScheme: 'dark',
});

aiChat.mount(rootElement);
await waitForRenderCycle();

const aiChatDom = rootElement.querySelector('.nlux-AiChat-root')!;

// Assert
expect(aiChatDom.className).toContain('nlux-colorScheme-dark');
});
});
});
Loading

0 comments on commit 44a9b0d

Please sign in to comment.