Skip to content

Commit 96959e9

Browse files
feat(AiCard): add new component aicard (#1544)
Adds new component AiCard. The AI ​​Card component (initially proposed as "Dynamic Callout") is a component proposed by the Evolution team and serves as an entry point for interaction with AI. It should be treated as an independent card (not as a variant of the Callout) and functions as a clickable element that takes the user to another AI screen/flow, simulating a "typing start" to encourage interaction. task: Telefonica/mistica-design#2632 --------- Co-authored-by: David Zayas <david.zayas.gomez@telefonica.com>
1 parent 2270adc commit 96959e9

7 files changed

Lines changed: 670 additions & 0 deletions

File tree

playroom/snippets.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,43 @@ const cardSnippets: Array<Snippet> = [
15851585
description="Description"
15861586
/>`,
15871587
},
1588+
{
1589+
group: 'Cards',
1590+
name: 'AiCard',
1591+
code: `
1592+
<AiCard
1593+
text="Lorem ipsum dolor sit amet, "
1594+
words={['consectetur', 'praesent', 'tempor', 'aliquam']}
1595+
onPress={() => {}}
1596+
borderColor={"linear-gradient(200deg, #AE42E459 17.51%, #BD4AFF59 38.3%, #EB3C7D59 82.5%)"}
1597+
asset={<svg
1598+
width="24"
1599+
height="24"
1600+
viewBox="0 0 24 24"
1601+
fill="none"
1602+
xmlns="http://www.w3.org/2000/svg"
1603+
>
1604+
<path
1605+
d="M6.53957 16.0123C6.71957 15.6223 7.26973 15.6223 7.44973 16.0123L8.23977 17.7623L9.99953 18.5523C10.3894 18.7324 10.3895 19.2825 9.99953 19.4625L8.24953 20.2525L7.45949 22.0123C7.27949 22.4022 6.71964 22.4021 6.53957 22.0123L5.74953 20.2623L3.99953 19.4722C3.60953 19.2922 3.60953 18.7421 3.99953 18.5621L5.74953 17.772L6.53957 16.0123ZM15.0073 5.99861C15.3574 5.21869 16.4767 5.21864 16.8267 5.99861L18.4165 9.49861L21.9165 11.0885C22.6965 11.4485 22.6965 12.5588 21.9165 12.9088L18.4165 14.4986L16.8267 17.9986C16.4666 18.7783 15.3575 18.7783 15.0073 17.9986L13.4165 14.4986L9.91652 12.9088C9.13679 12.5487 9.13685 11.4386 9.91652 11.0885L13.4165 9.49861L15.0073 5.99861ZM4.33254 1.97322C4.5126 1.58347 5.06166 1.58347 5.24172 1.97322L6.03176 3.72322L7.7925 4.51326C8.18211 4.69337 8.18215 5.24334 7.7925 5.42342L6.0425 6.21345L5.25246 7.97322C5.07248 8.36318 4.5126 8.3631 4.33254 7.97322L3.5425 6.22322L1.7925 5.43318C1.4025 5.25318 1.4025 4.70302 1.7925 4.52302L3.5425 3.73299L4.33254 1.97322Z"
1606+
fill="url(#paint0_linear_13522_368)"
1607+
/>
1608+
<defs>
1609+
<linearGradient
1610+
id="paint0_linear_13522_368"
1611+
x1="18.84"
1612+
y1="5.30652"
1613+
x2="5.4224"
1614+
y2="18.9388"
1615+
gradientUnits="userSpaceOnUse"
1616+
>
1617+
<stop stop-color="#AE42E4" />
1618+
<stop offset="0.32" stop-color="#BD4AFF" />
1619+
<stop offset="1" stop-color="#EF7E9C" />
1620+
</linearGradient>
1621+
</defs>
1622+
</svg>}
1623+
/>`,
1624+
},
15881625
];
15891626

15901627
const titlesSnippets: Array<Snippet> = [

src/__tests__/ai-card-test.tsx

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import * as React from 'react';
2+
import {render, screen} from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import {AiCard} from '../community';
5+
import ThemeContextProvider from '../theme-context-provider';
6+
import {makeTheme} from './test-utils';
7+
8+
beforeAll(() => {
9+
Object.defineProperty(window, 'IntersectionObserver', {
10+
writable: true,
11+
configurable: true,
12+
value: jest.fn().mockImplementation((callback: IntersectionObserverCallback) => ({
13+
observe: jest.fn((element: Element) => {
14+
callback(
15+
[{isIntersecting: true, target: element} as IntersectionObserverEntry],
16+
{} as IntersectionObserver
17+
);
18+
}),
19+
disconnect: jest.fn(),
20+
unobserve: jest.fn(),
21+
})),
22+
});
23+
});
24+
25+
afterEach(() => {
26+
jest.useRealTimers();
27+
jest.restoreAllMocks();
28+
});
29+
30+
const mockPrefersReducedMotion = () => {
31+
jest.spyOn(window, 'matchMedia').mockImplementation((query) => ({
32+
matches: query === '(prefers-reduced-motion: reduce)',
33+
addListener: jest.fn(),
34+
removeListener: jest.fn(),
35+
addEventListener: jest.fn(),
36+
removeEventListener: jest.fn(),
37+
media: query,
38+
onchange: null,
39+
dispatchEvent: jest.fn(),
40+
}));
41+
};
42+
43+
test('renders static text', () => {
44+
render(
45+
<ThemeContextProvider theme={makeTheme()}>
46+
<AiCard text="Hello world" />
47+
</ThemeContextProvider>
48+
);
49+
50+
expect(screen.getByTestId('AiCard').textContent).toContain('Hello world');
51+
});
52+
53+
test('is non-interactive when no onPress is provided', () => {
54+
render(
55+
<ThemeContextProvider theme={makeTheme()}>
56+
<AiCard text="Hello" />
57+
</ThemeContextProvider>
58+
);
59+
60+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
61+
});
62+
63+
test('renders as an accessible button when onPress is provided', () => {
64+
render(
65+
<ThemeContextProvider theme={makeTheme()}>
66+
<AiCard text="Hello" onPress={() => {}} />
67+
</ThemeContextProvider>
68+
);
69+
70+
expect(screen.getByRole('button', {name: 'Hello'})).toBeInTheDocument();
71+
});
72+
73+
test('renders as an accessible link when href is provided', () => {
74+
render(
75+
<ThemeContextProvider theme={makeTheme()}>
76+
<AiCard text="Hello " words={['world']} href="https://example.com" />
77+
</ThemeContextProvider>
78+
);
79+
80+
expect(screen.getByRole('link', {name: 'Hello world'})).toBeInTheDocument();
81+
});
82+
83+
test('calls onPress when clicked', async () => {
84+
const handlePress = jest.fn();
85+
render(
86+
<ThemeContextProvider theme={makeTheme()}>
87+
<AiCard text="Hello" onPress={handlePress} />
88+
</ThemeContextProvider>
89+
);
90+
91+
await userEvent.click(screen.getByRole('button'));
92+
93+
expect(handlePress).toHaveBeenCalledTimes(1);
94+
});
95+
96+
test('shows the last word immediately when user prefers reduced motion', () => {
97+
mockPrefersReducedMotion();
98+
99+
render(
100+
<ThemeContextProvider theme={makeTheme()}>
101+
<AiCard text="Check the " words={['weather', 'news']} onPress={() => {}} />
102+
</ThemeContextProvider>
103+
);
104+
105+
expect(screen.getByRole('button', {name: 'Check the news'})).toBeInTheDocument();
106+
});
107+
108+
test('animates words: typed word appears in the card', () => {
109+
jest.useFakeTimers();
110+
111+
render(
112+
<ThemeContextProvider theme={makeTheme()}>
113+
<AiCard text="" words={['hi']} onPress={() => {}} />
114+
</ThemeContextProvider>
115+
);
116+
117+
React.act(() => jest.advanceTimersByTime(500));
118+
119+
expect(screen.getByTestId('AiCard').textContent).toContain('hi');
120+
});
121+
122+
test('animates at most 4 words', () => {
123+
mockPrefersReducedMotion();
124+
125+
render(
126+
<ThemeContextProvider theme={makeTheme()}>
127+
<AiCard text="Find " words={['a', 'b', 'c', 'd', 'e']} onPress={() => {}} />
128+
</ThemeContextProvider>
129+
);
130+
131+
expect(screen.getByRole('button', {name: 'Find d'})).toBeInTheDocument();
132+
});
133+
134+
test('ignores empty and whitespace-only entries in words', () => {
135+
mockPrefersReducedMotion();
136+
137+
render(
138+
<ThemeContextProvider theme={makeTheme()}>
139+
<AiCard text="" words={['', ' ', 'valid']} onPress={() => {}} />
140+
</ThemeContextProvider>
141+
);
142+
143+
expect(screen.getByRole('button', {name: 'valid'})).toBeInTheDocument();
144+
});
145+
146+
test('passes dataAttributes to the container element', () => {
147+
render(
148+
<ThemeContextProvider theme={makeTheme()}>
149+
<AiCard text="Hello" dataAttributes={{testid: 'my-ai-card'}} />
150+
</ThemeContextProvider>
151+
);
152+
153+
expect(screen.getByTestId('my-ai-card')).toBeInTheDocument();
154+
});
155+
156+
test('aria-label prop overrides the auto-generated label', () => {
157+
render(
158+
<ThemeContextProvider theme={makeTheme()}>
159+
<AiCard text="Hello" words={['world']} aria-label="Custom label" onPress={() => {}} />
160+
</ThemeContextProvider>
161+
);
162+
163+
expect(screen.getByRole('button', {name: 'Custom label'})).toBeInTheDocument();
164+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as React from 'react';
2+
import AiCard from '../ai-card';
3+
import Box from '../../box';
4+
import ResponsiveLayout from '../../responsive-layout';
5+
import IconArtificialIntelligenceFilled from '../../generated/mistica-icons/icon-artificial-intelligence-filled';
6+
import {vars} from '../../skins/skin-contract.css';
7+
8+
export default {
9+
title: 'Community/AiCard',
10+
parameters: {fullScreen: true},
11+
};
12+
13+
const assetOptions: Record<string, React.ReactElement> = {
14+
'AI Icon (gradient)': (
15+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
16+
<path
17+
d="M6.53957 16.0123C6.71957 15.6223 7.26973 15.6223 7.44973 16.0123L8.23977 17.7623L9.99953 18.5523C10.3894 18.7324 10.3895 19.2825 9.99953 19.4625L8.24953 20.2525L7.45949 22.0123C7.27949 22.4022 6.71964 22.4021 6.53957 22.0123L5.74953 20.2623L3.99953 19.4722C3.60953 19.2922 3.60953 18.7421 3.99953 18.5621L5.74953 17.772L6.53957 16.0123ZM15.0073 5.99861C15.3574 5.21869 16.4767 5.21864 16.8267 5.99861L18.4165 9.49861L21.9165 11.0885C22.6965 11.4485 22.6965 12.5588 21.9165 12.9088L18.4165 14.4986L16.8267 17.9986C16.4666 18.7783 15.3575 18.7783 15.0073 17.9986L13.4165 14.4986L9.91652 12.9088C9.13679 12.5487 9.13685 11.4386 9.91652 11.0885L13.4165 9.49861L15.0073 5.99861ZM4.33254 1.97322C4.5126 1.58347 5.06166 1.58347 5.24172 1.97322L6.03176 3.72322L7.7925 4.51326C8.18211 4.69337 8.18215 5.24334 7.7925 5.42342L6.0425 6.21345L5.25246 7.97322C5.07248 8.36318 4.5126 8.3631 4.33254 7.97322L3.5425 6.22322L1.7925 5.43318C1.4025 5.25318 1.4025 4.70302 1.7925 4.52302L3.5425 3.73299L4.33254 1.97322Z"
18+
fill="url(#ai-icon-gradient)"
19+
/>
20+
<defs>
21+
<linearGradient
22+
id="ai-icon-gradient"
23+
x1="18.84"
24+
y1="5.30652"
25+
x2="5.4224"
26+
y2="18.9388"
27+
gradientUnits="userSpaceOnUse"
28+
>
29+
<stop stopColor="#AE42E4" />
30+
<stop offset="0.32" stopColor="#BD4AFF" />
31+
<stop offset="1" stopColor="#EF7E9C" />
32+
</linearGradient>
33+
</defs>
34+
</svg>
35+
),
36+
'AI Icon (brand)': <IconArtificialIntelligenceFilled color={vars.colors.textBrand} />,
37+
};
38+
39+
type Args = {
40+
text: string;
41+
words: string | Array<string>;
42+
deleteChars: number;
43+
lineBreakAtChars: number;
44+
borderColor: string;
45+
asset: string;
46+
};
47+
48+
const parseWords = (raw: string | Array<string>): Array<string> =>
49+
Array.isArray(raw)
50+
? raw.map((w) => String(w).trim()).filter(Boolean)
51+
: String(raw)
52+
.split(',')
53+
.map((word) => word.trim())
54+
.filter(Boolean);
55+
56+
export const Default: StoryComponent<Args> = ({
57+
text,
58+
words,
59+
deleteChars,
60+
lineBreakAtChars,
61+
borderColor,
62+
asset,
63+
}) => (
64+
<ResponsiveLayout>
65+
<Box paddingY={24}>
66+
<AiCard
67+
text={text}
68+
words={parseWords(words)}
69+
deleteChars={deleteChars > 0 ? deleteChars : undefined}
70+
lineBreakAtChars={lineBreakAtChars > 0 ? lineBreakAtChars : undefined}
71+
borderColor={borderColor || undefined}
72+
asset={assetOptions[asset]}
73+
onPress={() => {}}
74+
dataAttributes={{testid: 'ai-card'}}
75+
/>
76+
</Box>
77+
</ResponsiveLayout>
78+
);
79+
80+
Default.storyName = 'AiCard';
81+
Default.args = {
82+
text: 'Lorem ipsum dolor sit amet, ',
83+
words: ['consectetur', 'praesent', 'tempor', 'aliquam'],
84+
deleteChars: 0,
85+
lineBreakAtChars: 0,
86+
borderColor: 'linear-gradient(200deg, #AE42E459 17.51%, #BD4AFF59 38.3%, #EB3C7D59 82.5%)',
87+
asset: 'AI Icon (gradient)',
88+
};
89+
Default.argTypes = {
90+
deleteChars: {control: {type: 'number', min: 0, step: 1}},
91+
lineBreakAtChars: {control: {type: 'number', min: 0, step: 1}},
92+
borderColor: {control: {type: 'text'}},
93+
words: {
94+
control: {type: 'array'},
95+
description: 'List of words for the AiCard animation.',
96+
},
97+
asset: {
98+
options: Object.keys(assetOptions),
99+
control: {type: 'select'},
100+
},
101+
};

0 commit comments

Comments
 (0)