@cloud-ru/ds-ai-suggestion 1.0.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/CHANGELOG.md +8 -0
- package/LICENSE +201 -0
- package/README.md +226 -0
- package/dist/cjs/AiSuggestionParent/AiSuggestionParent.d.ts +7 -0
- package/dist/cjs/AiSuggestionParent/AiSuggestionParent.js +107 -0
- package/dist/cjs/AiSuggestionParent/AiSuggestionParentChip.d.ts +26 -0
- package/dist/cjs/AiSuggestionParent/AiSuggestionParentChip.js +24 -0
- package/dist/cjs/AiSuggestionParent/animation/AnimatedExpandableItem.d.ts +12 -0
- package/dist/cjs/AiSuggestionParent/animation/AnimatedExpandableItem.js +24 -0
- package/dist/cjs/AiSuggestionParent/animation/constants.d.ts +33 -0
- package/dist/cjs/AiSuggestionParent/animation/constants.js +69 -0
- package/dist/cjs/AiSuggestionParent/animation/styles.module.css +23 -0
- package/dist/cjs/AiSuggestionParent/constants.d.ts +9 -0
- package/dist/cjs/AiSuggestionParent/constants.js +12 -0
- package/dist/cjs/AiSuggestionParent/context/AiSuggestionParentGroupContext.d.ts +13 -0
- package/dist/cjs/AiSuggestionParent/context/AiSuggestionParentGroupContext.js +25 -0
- package/dist/cjs/AiSuggestionParent/index.d.ts +4 -0
- package/dist/cjs/AiSuggestionParent/index.js +24 -0
- package/dist/cjs/AiSuggestionParent/styles.module.css +156 -0
- package/dist/cjs/AiSuggestionParent/types.d.ts +44 -0
- package/dist/cjs/AiSuggestionParent/types.js +2 -0
- package/dist/cjs/AiSuggestionParent/utils/hasExclusiveParentGroup.d.ts +2 -0
- package/dist/cjs/AiSuggestionParent/utils/hasExclusiveParentGroup.js +7 -0
- package/dist/cjs/AiSuggestionParent/utils/itemGuards.d.ts +3 -0
- package/dist/cjs/AiSuggestionParent/utils/itemGuards.js +11 -0
- package/dist/cjs/AiSuggestionSimple/AiSuggestionSimple.d.ts +18 -0
- package/dist/cjs/AiSuggestionSimple/AiSuggestionSimple.js +17 -0
- package/dist/cjs/AiSuggestionSimple/constants.d.ts +18 -0
- package/dist/cjs/AiSuggestionSimple/constants.js +21 -0
- package/dist/cjs/AiSuggestionSimple/index.d.ts +3 -0
- package/dist/cjs/AiSuggestionSimple/index.js +24 -0
- package/dist/cjs/AiSuggestionSimple/styles.module.css +138 -0
- package/dist/cjs/AiSuggestionSimple/types.d.ts +5 -0
- package/dist/cjs/AiSuggestionSimple/types.js +2 -0
- package/dist/cjs/AiSuggestionSimple/utils.d.ts +2 -0
- package/dist/cjs/AiSuggestionSimple/utils.js +7 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/style.css +317 -0
- package/dist/esm/AiSuggestionParent/AiSuggestionParent.d.ts +7 -0
- package/dist/esm/AiSuggestionParent/AiSuggestionParent.js +101 -0
- package/dist/esm/AiSuggestionParent/AiSuggestionParentChip.d.ts +26 -0
- package/dist/esm/AiSuggestionParent/AiSuggestionParentChip.js +18 -0
- package/dist/esm/AiSuggestionParent/animation/AnimatedExpandableItem.d.ts +12 -0
- package/dist/esm/AiSuggestionParent/animation/AnimatedExpandableItem.js +17 -0
- package/dist/esm/AiSuggestionParent/animation/constants.d.ts +33 -0
- package/dist/esm/AiSuggestionParent/animation/constants.js +63 -0
- package/dist/esm/AiSuggestionParent/animation/styles.module.css +23 -0
- package/dist/esm/AiSuggestionParent/constants.d.ts +9 -0
- package/dist/esm/AiSuggestionParent/constants.js +9 -0
- package/dist/esm/AiSuggestionParent/context/AiSuggestionParentGroupContext.d.ts +13 -0
- package/dist/esm/AiSuggestionParent/context/AiSuggestionParentGroupContext.js +21 -0
- package/dist/esm/AiSuggestionParent/index.d.ts +4 -0
- package/dist/esm/AiSuggestionParent/index.js +4 -0
- package/dist/esm/AiSuggestionParent/styles.module.css +156 -0
- package/dist/esm/AiSuggestionParent/types.d.ts +44 -0
- package/dist/esm/AiSuggestionParent/types.js +1 -0
- package/dist/esm/AiSuggestionParent/utils/hasExclusiveParentGroup.d.ts +2 -0
- package/dist/esm/AiSuggestionParent/utils/hasExclusiveParentGroup.js +4 -0
- package/dist/esm/AiSuggestionParent/utils/itemGuards.d.ts +3 -0
- package/dist/esm/AiSuggestionParent/utils/itemGuards.js +7 -0
- package/dist/esm/AiSuggestionSimple/AiSuggestionSimple.d.ts +18 -0
- package/dist/esm/AiSuggestionSimple/AiSuggestionSimple.js +11 -0
- package/dist/esm/AiSuggestionSimple/constants.d.ts +18 -0
- package/dist/esm/AiSuggestionSimple/constants.js +18 -0
- package/dist/esm/AiSuggestionSimple/index.d.ts +3 -0
- package/dist/esm/AiSuggestionSimple/index.js +3 -0
- package/dist/esm/AiSuggestionSimple/styles.module.css +138 -0
- package/dist/esm/AiSuggestionSimple/types.d.ts +5 -0
- package/dist/esm/AiSuggestionSimple/types.js +1 -0
- package/dist/esm/AiSuggestionSimple/utils.d.ts +2 -0
- package/dist/esm/AiSuggestionSimple/utils.js +4 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/style.css +317 -0
- package/dist/tsconfig.cjs.tsbuildinfo +1 -0
- package/dist/tsconfig.esm.tsbuildinfo +1 -0
- package/package.json +45 -0
- package/src/AiSuggestionParent/AiSuggestionParent.tsx +257 -0
- package/src/AiSuggestionParent/AiSuggestionParentChip.tsx +79 -0
- package/src/AiSuggestionParent/animation/AnimatedExpandableItem.tsx +53 -0
- package/src/AiSuggestionParent/animation/constants.ts +77 -0
- package/src/AiSuggestionParent/animation/styles.module.scss +25 -0
- package/src/AiSuggestionParent/constants.ts +10 -0
- package/src/AiSuggestionParent/context/AiSuggestionParentGroupContext.tsx +44 -0
- package/src/AiSuggestionParent/index.ts +4 -0
- package/src/AiSuggestionParent/styles.module.scss +197 -0
- package/src/AiSuggestionParent/types.ts +49 -0
- package/src/AiSuggestionParent/utils/hasExclusiveParentGroup.ts +6 -0
- package/src/AiSuggestionParent/utils/itemGuards.ts +10 -0
- package/src/AiSuggestionSimple/AiSuggestionSimple.tsx +72 -0
- package/src/AiSuggestionSimple/constants.ts +21 -0
- package/src/AiSuggestionSimple/index.ts +3 -0
- package/src/AiSuggestionSimple/styles.module.scss +166 -0
- package/src/AiSuggestionSimple/types.ts +7 -0
- package/src/AiSuggestionSimple/utils.ts +6 -0
- package/src/index.ts +2 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cloud-ru/ds-ai-suggestion",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI-подсказки — простой чип и раскрывающаяся группа с вложенными подсказками.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/cloud-ru-tech/snack-v2.git",
|
|
8
|
+
"directory": "packages/ai-suggestion"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/cloud-ru-tech/snack-v2/tree/master/packages/ai-suggestion",
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"author": "Артем Сауляк <avsaulyak@cloud.ru>",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/cjs/index.js",
|
|
15
|
+
"module": "./dist/esm/index.js",
|
|
16
|
+
"types": "./dist/esm/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"source": "./src/index.ts",
|
|
20
|
+
"types": "./dist/esm/index.d.ts",
|
|
21
|
+
"import": "./dist/esm/index.js",
|
|
22
|
+
"require": "./dist/cjs/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"./CHANGELOG.md",
|
|
29
|
+
"./LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": [
|
|
32
|
+
"*.css",
|
|
33
|
+
"**/*.css"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"classnames": "^2.5.1",
|
|
37
|
+
"framer-motion": "^12.42.2",
|
|
38
|
+
"@cloud-ru/ds-icons": "^1.0.0",
|
|
39
|
+
"@cloud-ru/ds-utils": "^1.0.0",
|
|
40
|
+
"@cloud-ru/ds-materials": "^1.0.0"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { extractSupportProps, useUncontrolledProp } from '@cloud-ru/ds-utils';
|
|
2
|
+
import cn from 'classnames';
|
|
3
|
+
import { MouseEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { AiSuggestionSimple } from '../AiSuggestionSimple';
|
|
6
|
+
import { APPEARANCE, SIZE } from '../AiSuggestionSimple/constants';
|
|
7
|
+
import { Size } from '../AiSuggestionSimple/types';
|
|
8
|
+
import { AiSuggestionParentChip } from './AiSuggestionParentChip';
|
|
9
|
+
import { AnimatedExpandableItem, AnimatedTriggerWrap } from './animation/AnimatedExpandableItem';
|
|
10
|
+
import { TEST_IDS } from './constants';
|
|
11
|
+
import { AiSuggestionParentGroupProvider, useAiSuggestionParentGroup } from './context/AiSuggestionParentGroupContext';
|
|
12
|
+
import styles from './styles.module.scss';
|
|
13
|
+
import {
|
|
14
|
+
AiSuggestionParentItem,
|
|
15
|
+
AiSuggestionParentNestedItem,
|
|
16
|
+
AiSuggestionParentProps,
|
|
17
|
+
AiSuggestionParentSuggestionItem,
|
|
18
|
+
} from './types';
|
|
19
|
+
import { hasExclusiveParentGroup } from './utils/hasExclusiveParentGroup';
|
|
20
|
+
import { isNestedItem, isSuggestionItem } from './utils/itemGuards';
|
|
21
|
+
|
|
22
|
+
type AiSuggestionParentInternalProps = AiSuggestionParentProps & {
|
|
23
|
+
/** Родительская группа раскрыта — управляет видимостью вложенной ветки */
|
|
24
|
+
shown?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type NestedAiSuggestionParentProps = {
|
|
28
|
+
index: number;
|
|
29
|
+
total: number;
|
|
30
|
+
shown: boolean;
|
|
31
|
+
node: AiSuggestionParentNestedItem;
|
|
32
|
+
size: NonNullable<AiSuggestionParentProps['size']>;
|
|
33
|
+
groupKey?: string;
|
|
34
|
+
onItemClick?: AiSuggestionParentProps['onItemClick'];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type RenderSuggestionLeafProps = {
|
|
38
|
+
item: AiSuggestionParentSuggestionItem;
|
|
39
|
+
index: number;
|
|
40
|
+
total: number;
|
|
41
|
+
shown: boolean;
|
|
42
|
+
size: Size;
|
|
43
|
+
onLeafClick?(event: MouseEvent<HTMLButtonElement>): void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function renderSuggestionLeaf({ item, index, total, shown, size, onLeafClick }: RenderSuggestionLeafProps) {
|
|
47
|
+
const key = item.key ?? String(index);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<AnimatedExpandableItem key={key} index={index} total={total} shown={shown}>
|
|
51
|
+
<AiSuggestionSimple
|
|
52
|
+
label={item.label}
|
|
53
|
+
icon={item.icon}
|
|
54
|
+
size={size}
|
|
55
|
+
disabled={item.disabled}
|
|
56
|
+
appearance={APPEARANCE.Primary}
|
|
57
|
+
tabIndex={shown ? 0 : -1}
|
|
58
|
+
onClick={(event: MouseEvent<HTMLButtonElement>) => {
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
item.onClick?.(event);
|
|
61
|
+
onLeafClick?.(event);
|
|
62
|
+
}}
|
|
63
|
+
/>
|
|
64
|
+
</AnimatedExpandableItem>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function useNestedExpandedState(groupKey: string | undefined, shown: boolean) {
|
|
69
|
+
const group = useAiSuggestionParentGroup();
|
|
70
|
+
const isGroupControlled = group !== null && groupKey !== undefined;
|
|
71
|
+
const [localExpanded, setLocalExpanded] = useState(false);
|
|
72
|
+
const expanded = isGroupControlled ? group.expandedKey === groupKey : localExpanded;
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!shown) {
|
|
76
|
+
if (isGroupControlled && groupKey) {
|
|
77
|
+
group.collapseKey(groupKey);
|
|
78
|
+
} else {
|
|
79
|
+
setLocalExpanded(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}, [group, groupKey, isGroupControlled, shown]);
|
|
83
|
+
|
|
84
|
+
const handleExpandedChange = useCallback(
|
|
85
|
+
(next: boolean) => {
|
|
86
|
+
if (isGroupControlled && groupKey) {
|
|
87
|
+
group.requestExpand(groupKey);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setLocalExpanded(next);
|
|
92
|
+
},
|
|
93
|
+
[group, groupKey, isGroupControlled],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return { expanded, handleExpandedChange };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function NestedAiSuggestionParent({
|
|
100
|
+
index,
|
|
101
|
+
total,
|
|
102
|
+
shown,
|
|
103
|
+
node,
|
|
104
|
+
size,
|
|
105
|
+
groupKey,
|
|
106
|
+
onItemClick,
|
|
107
|
+
}: NestedAiSuggestionParentProps) {
|
|
108
|
+
const { expanded, handleExpandedChange } = useNestedExpandedState(groupKey, shown);
|
|
109
|
+
const childShown = shown && expanded;
|
|
110
|
+
const items = node.items ?? [];
|
|
111
|
+
const nestedChildren = items.filter(isNestedItem);
|
|
112
|
+
const leaves = items.filter(isSuggestionItem);
|
|
113
|
+
const hasExclusiveGroup = hasExclusiveParentGroup(items);
|
|
114
|
+
|
|
115
|
+
const nestedContent = (
|
|
116
|
+
<>
|
|
117
|
+
{nestedChildren.map((child, childIndex) => {
|
|
118
|
+
const childKey = child.key ?? `${node.label}-${child.label ?? childIndex}`;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<NestedAiSuggestionParent
|
|
122
|
+
key={childKey}
|
|
123
|
+
index={childIndex}
|
|
124
|
+
total={nestedChildren.length}
|
|
125
|
+
shown={childShown}
|
|
126
|
+
node={child}
|
|
127
|
+
size={size}
|
|
128
|
+
groupKey={hasExclusiveGroup ? childKey : undefined}
|
|
129
|
+
onItemClick={onItemClick}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
{leaves.map((leaf, leafIndex) =>
|
|
134
|
+
renderSuggestionLeaf({
|
|
135
|
+
item: leaf,
|
|
136
|
+
index: leafIndex,
|
|
137
|
+
total: leaves.length,
|
|
138
|
+
shown: childShown,
|
|
139
|
+
size,
|
|
140
|
+
}),
|
|
141
|
+
)}
|
|
142
|
+
</>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
<AnimatedExpandableItem index={index} total={total} shown={shown}>
|
|
148
|
+
<AiSuggestionParentChip
|
|
149
|
+
label={node.label}
|
|
150
|
+
icon={node.icon}
|
|
151
|
+
size={size}
|
|
152
|
+
disabled={node.disabled}
|
|
153
|
+
expanded={expanded}
|
|
154
|
+
onExpandedChange={handleExpandedChange}
|
|
155
|
+
tabIndex={shown ? 0 : -1}
|
|
156
|
+
/>
|
|
157
|
+
</AnimatedExpandableItem>
|
|
158
|
+
{hasExclusiveGroup ? (
|
|
159
|
+
<AiSuggestionParentGroupProvider>{nestedContent}</AiSuggestionParentGroupProvider>
|
|
160
|
+
) : (
|
|
161
|
+
nestedContent
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function wrapExclusiveItems(items: AiSuggestionParentItem[], content: ReactNode) {
|
|
168
|
+
return hasExclusiveParentGroup(items) ? (
|
|
169
|
+
<AiSuggestionParentGroupProvider>{content}</AiSuggestionParentGroupProvider>
|
|
170
|
+
) : (
|
|
171
|
+
content
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function AiSuggestionParent({
|
|
176
|
+
label = 'Label text',
|
|
177
|
+
icon,
|
|
178
|
+
size = SIZE.S,
|
|
179
|
+
disabled = false,
|
|
180
|
+
expanded: expandedProp,
|
|
181
|
+
defaultExpanded = false,
|
|
182
|
+
onExpandedChange,
|
|
183
|
+
items = [],
|
|
184
|
+
onItemClick,
|
|
185
|
+
className,
|
|
186
|
+
shown: shownProp = true,
|
|
187
|
+
...rest
|
|
188
|
+
}: AiSuggestionParentInternalProps) {
|
|
189
|
+
const [expanded, setExpanded] = useUncontrolledProp(expandedProp, defaultExpanded, onExpandedChange);
|
|
190
|
+
const shown = shownProp;
|
|
191
|
+
const childShown = shown && expanded;
|
|
192
|
+
const totalItems = items.length;
|
|
193
|
+
const hasExclusiveGroup = hasExclusiveParentGroup(items);
|
|
194
|
+
const rootTestId = rest['data-test-id'] ?? TEST_IDS.root;
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!shown && expanded) {
|
|
198
|
+
setExpanded(false);
|
|
199
|
+
}
|
|
200
|
+
}, [shown, expanded, setExpanded]);
|
|
201
|
+
|
|
202
|
+
const handleExpandedChange = useCallback(
|
|
203
|
+
(next: boolean) => {
|
|
204
|
+
if (disabled) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
setExpanded(next);
|
|
209
|
+
},
|
|
210
|
+
[disabled, setExpanded],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const renderItem = (item: AiSuggestionParentItem, index: number) => {
|
|
214
|
+
if (isNestedItem(item)) {
|
|
215
|
+
const key = item.key ?? String(index);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<NestedAiSuggestionParent
|
|
219
|
+
key={key}
|
|
220
|
+
index={index}
|
|
221
|
+
total={totalItems}
|
|
222
|
+
shown={childShown}
|
|
223
|
+
node={item}
|
|
224
|
+
size={size}
|
|
225
|
+
groupKey={hasExclusiveGroup ? key : undefined}
|
|
226
|
+
onItemClick={onItemClick}
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return renderSuggestionLeaf({
|
|
232
|
+
item,
|
|
233
|
+
index,
|
|
234
|
+
total: totalItems,
|
|
235
|
+
shown: childShown,
|
|
236
|
+
size,
|
|
237
|
+
onLeafClick: event => onItemClick?.(index, event),
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className={cn(styles.root, className)} {...extractSupportProps(rest)} data-flatten data-test-id={rootTestId}>
|
|
243
|
+
<AnimatedTriggerWrap>
|
|
244
|
+
<AiSuggestionParentChip
|
|
245
|
+
label={label}
|
|
246
|
+
icon={icon}
|
|
247
|
+
size={size}
|
|
248
|
+
disabled={disabled}
|
|
249
|
+
expanded={expanded}
|
|
250
|
+
onExpandedChange={handleExpandedChange}
|
|
251
|
+
tabIndex={shown ? undefined : -1}
|
|
252
|
+
/>
|
|
253
|
+
</AnimatedTriggerWrap>
|
|
254
|
+
{wrapExclusiveItems(items, items.map(renderItem))}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ChevronRightSVG } from '@cloud-ru/ds-icons/interface/system';
|
|
2
|
+
import { extractSupportProps } from '@cloud-ru/ds-utils';
|
|
3
|
+
import cn from 'classnames';
|
|
4
|
+
import { ButtonHTMLAttributes, forwardRef, MouseEvent, ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import { SIZE } from '../AiSuggestionSimple/constants';
|
|
7
|
+
import { Size } from '../AiSuggestionSimple/types';
|
|
8
|
+
import { TEST_IDS } from './constants';
|
|
9
|
+
import styles from './styles.module.scss';
|
|
10
|
+
|
|
11
|
+
export type AiSuggestionParentChipProps = {
|
|
12
|
+
label?: string;
|
|
13
|
+
icon?: ReactNode;
|
|
14
|
+
size?: Size;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
expanded?: boolean;
|
|
17
|
+
onExpandedChange?(expanded: boolean): void;
|
|
18
|
+
chevron?: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
|
|
21
|
+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const AiSuggestionParentChip = forwardRef<HTMLButtonElement, AiSuggestionParentChipProps>(
|
|
25
|
+
function AiSuggestionParentChip(
|
|
26
|
+
{
|
|
27
|
+
label = 'Label text',
|
|
28
|
+
icon,
|
|
29
|
+
size = SIZE.S,
|
|
30
|
+
disabled = false,
|
|
31
|
+
expanded = false,
|
|
32
|
+
onExpandedChange,
|
|
33
|
+
chevron,
|
|
34
|
+
className,
|
|
35
|
+
type = 'button',
|
|
36
|
+
onClick,
|
|
37
|
+
...rest
|
|
38
|
+
},
|
|
39
|
+
ref,
|
|
40
|
+
) {
|
|
41
|
+
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
42
|
+
onClick?.(event);
|
|
43
|
+
if (event.defaultPrevented || disabled) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onExpandedChange?.(!expanded);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
ref={ref}
|
|
53
|
+
type={type}
|
|
54
|
+
className={cn(styles.chip, className)}
|
|
55
|
+
{...extractSupportProps(rest)}
|
|
56
|
+
data-size={size}
|
|
57
|
+
data-activated={expanded || undefined}
|
|
58
|
+
data-test-id={TEST_IDS.trigger}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
aria-expanded={expanded}
|
|
61
|
+
onClick={handleClick}
|
|
62
|
+
>
|
|
63
|
+
{icon !== undefined && (
|
|
64
|
+
<span className={styles.icon} aria-hidden>
|
|
65
|
+
{icon}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
{label !== undefined && (
|
|
69
|
+
<span className={styles.labelContainer}>
|
|
70
|
+
<span className={styles.label}>{label}</span>
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
<span className={styles.chevron} data-test-id={TEST_IDS.chevron} aria-hidden>
|
|
74
|
+
{chevron ?? <ChevronRightSVG className={styles.chevronIcon} size={24} />}
|
|
75
|
+
</span>
|
|
76
|
+
</button>
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import cn from 'classnames';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { GAP_PX, innerChipTransition, layoutSpring, outerChipTransition } from './constants';
|
|
6
|
+
import styles from './styles.module.scss';
|
|
7
|
+
|
|
8
|
+
export type AnimatedExpandableItemProps = {
|
|
9
|
+
index: number;
|
|
10
|
+
total: number;
|
|
11
|
+
shown: boolean;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function AnimatedExpandableItem({ index, total, shown, children }: AnimatedExpandableItemProps) {
|
|
16
|
+
return (
|
|
17
|
+
<motion.div
|
|
18
|
+
layout='position'
|
|
19
|
+
initial={false}
|
|
20
|
+
animate={{
|
|
21
|
+
width: shown ? 'auto' : 0,
|
|
22
|
+
marginRight: shown ? GAP_PX : 0,
|
|
23
|
+
}}
|
|
24
|
+
transition={outerChipTransition(shown)}
|
|
25
|
+
className={styles.outerChip}
|
|
26
|
+
aria-hidden={!shown}
|
|
27
|
+
>
|
|
28
|
+
<motion.div
|
|
29
|
+
initial={false}
|
|
30
|
+
animate={{
|
|
31
|
+
scale: shown ? 1 : 0,
|
|
32
|
+
opacity: shown ? 1 : 0,
|
|
33
|
+
}}
|
|
34
|
+
transition={innerChipTransition(index, total, shown)}
|
|
35
|
+
className={cn(styles.innerChip, { [styles.innerChipHidden]: !shown })}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</motion.div>
|
|
39
|
+
</motion.div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type AnimatedTriggerWrapProps = {
|
|
44
|
+
children: ReactNode;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function AnimatedTriggerWrap({ children }: AnimatedTriggerWrapProps) {
|
|
48
|
+
return (
|
|
49
|
+
<motion.div layout='position' transition={{ layout: layoutSpring }} className={styles.triggerWrap}>
|
|
50
|
+
{children}
|
|
51
|
+
</motion.div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Transition } from 'framer-motion';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Animation contract — двухслойный chip + FLIP-spring layout (1:1 из ai-components).
|
|
5
|
+
*
|
|
6
|
+
* Per-property timing:
|
|
7
|
+
* width / marginRight / scale → sync, одна duration, один easing.
|
|
8
|
+
* opacity → со stagger (visual ripple).
|
|
9
|
+
* layout (FLIP) → spring (110, 22, 1).
|
|
10
|
+
*
|
|
11
|
+
* Длительности:
|
|
12
|
+
* enter width/scale → 650ms opacity → 450ms, stagger 60ms
|
|
13
|
+
* exit width/scale → 550ms opacity → 280ms, stagger 80ms
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** easings:
|
|
17
|
+
* enter — easeOutQuart
|
|
18
|
+
* exit — easeInQuart
|
|
19
|
+
* bubble — easeOutBack overshoot ~5% для inner scale
|
|
20
|
+
*/
|
|
21
|
+
export const ENTER_EASE: Transition['ease'] = [0.22, 1, 0.36, 1];
|
|
22
|
+
export const EXIT_EASE: Transition['ease'] = [0.64, 0, 0.78, 0];
|
|
23
|
+
export const BUBBLE_ENTER_EASE: Transition['ease'] = [0.34, 1.5, 0.64, 1];
|
|
24
|
+
|
|
25
|
+
export const ENTER_WIDTH_S = 0.65;
|
|
26
|
+
export const ENTER_OPACITY_S = 0.45;
|
|
27
|
+
|
|
28
|
+
export const EXIT_OPACITY_S = 0.28;
|
|
29
|
+
export const EXIT_WIDTH_S = 0.55;
|
|
30
|
+
|
|
31
|
+
export const STAGGER_FORWARD_OPACITY_S = 0.06;
|
|
32
|
+
export const STAGGER_BACKWARD_OPACITY_S = 0.08;
|
|
33
|
+
export const MAX_STAGGER_WINDOW_S = 0.5;
|
|
34
|
+
|
|
35
|
+
export const GAP_PX = 4;
|
|
36
|
+
|
|
37
|
+
export const layoutSpring: Transition = {
|
|
38
|
+
type: 'spring',
|
|
39
|
+
stiffness: 110,
|
|
40
|
+
damping: 22,
|
|
41
|
+
mass: 1,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const cappedStagger = (idealPerChip: number, total: number, index: number): number => {
|
|
45
|
+
if (total <= 1) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const perChip = Math.min(idealPerChip, MAX_STAGGER_WINDOW_S / (total - 1));
|
|
50
|
+
|
|
51
|
+
return index * perChip;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sizeTransition = (activated: boolean): Transition => ({
|
|
55
|
+
duration: activated ? ENTER_WIDTH_S : EXIT_WIDTH_S,
|
|
56
|
+
ease: activated ? ENTER_EASE : EXIT_EASE,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const outerChipTransition = (activated: boolean): Transition => ({
|
|
60
|
+
width: sizeTransition(activated),
|
|
61
|
+
marginRight: sizeTransition(activated),
|
|
62
|
+
layout: layoutSpring,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const innerChipTransition = (index: number, total: number, activated: boolean): Transition => ({
|
|
66
|
+
scale: {
|
|
67
|
+
duration: activated ? ENTER_WIDTH_S : EXIT_WIDTH_S,
|
|
68
|
+
ease: activated ? BUBBLE_ENTER_EASE : EXIT_EASE,
|
|
69
|
+
},
|
|
70
|
+
opacity: {
|
|
71
|
+
duration: activated ? ENTER_OPACITY_S : EXIT_OPACITY_S,
|
|
72
|
+
ease: activated ? ENTER_EASE : EXIT_EASE,
|
|
73
|
+
delay: activated
|
|
74
|
+
? cappedStagger(STAGGER_FORWARD_OPACITY_S, total, index)
|
|
75
|
+
: cappedStagger(STAGGER_BACKWARD_OPACITY_S, total, total - 1 - index),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
@use '@cloud-ru/ds-figma-variables/build/scss/styles/styles.module' as base;
|
|
2
|
+
|
|
3
|
+
.outerChip {
|
|
4
|
+
contain: layout;
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
flex-shrink: 0;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.innerChip {
|
|
12
|
+
will-change: transform, opacity;
|
|
13
|
+
transform-origin: center;
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.innerChipHidden {
|
|
18
|
+
pointer-events: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.triggerWrap {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
flex-shrink: 0;
|
|
24
|
+
margin-right: base.$sn-primitive-dimension-4;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type AiSuggestionParentGroupContextValue = {
|
|
4
|
+
expandedKey: string | null;
|
|
5
|
+
requestExpand(key: string): void;
|
|
6
|
+
collapseKey(key: string): void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const AiSuggestionParentGroupContext = createContext<AiSuggestionParentGroupContextValue | null>(null);
|
|
10
|
+
|
|
11
|
+
export type AiSuggestionParentGroupProviderProps = {
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
initialExpandedKey?: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function AiSuggestionParentGroupProvider({
|
|
17
|
+
children,
|
|
18
|
+
initialExpandedKey = null,
|
|
19
|
+
}: AiSuggestionParentGroupProviderProps) {
|
|
20
|
+
const [expandedKey, setExpandedKey] = useState<string | null>(initialExpandedKey);
|
|
21
|
+
|
|
22
|
+
const requestExpand = useCallback((key: string) => {
|
|
23
|
+
setExpandedKey(prev => (prev === key ? null : key));
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const collapseKey = useCallback((key: string) => {
|
|
27
|
+
setExpandedKey(prev => (prev === key ? null : prev));
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
const value = useMemo(
|
|
31
|
+
() => ({
|
|
32
|
+
expandedKey,
|
|
33
|
+
requestExpand,
|
|
34
|
+
collapseKey,
|
|
35
|
+
}),
|
|
36
|
+
[collapseKey, expandedKey, requestExpand],
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return <AiSuggestionParentGroupContext.Provider value={value}>{children}</AiSuggestionParentGroupContext.Provider>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useAiSuggestionParentGroup() {
|
|
43
|
+
return useContext(AiSuggestionParentGroupContext);
|
|
44
|
+
}
|