@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +201 -0
  3. package/README.md +226 -0
  4. package/dist/cjs/AiSuggestionParent/AiSuggestionParent.d.ts +7 -0
  5. package/dist/cjs/AiSuggestionParent/AiSuggestionParent.js +107 -0
  6. package/dist/cjs/AiSuggestionParent/AiSuggestionParentChip.d.ts +26 -0
  7. package/dist/cjs/AiSuggestionParent/AiSuggestionParentChip.js +24 -0
  8. package/dist/cjs/AiSuggestionParent/animation/AnimatedExpandableItem.d.ts +12 -0
  9. package/dist/cjs/AiSuggestionParent/animation/AnimatedExpandableItem.js +24 -0
  10. package/dist/cjs/AiSuggestionParent/animation/constants.d.ts +33 -0
  11. package/dist/cjs/AiSuggestionParent/animation/constants.js +69 -0
  12. package/dist/cjs/AiSuggestionParent/animation/styles.module.css +23 -0
  13. package/dist/cjs/AiSuggestionParent/constants.d.ts +9 -0
  14. package/dist/cjs/AiSuggestionParent/constants.js +12 -0
  15. package/dist/cjs/AiSuggestionParent/context/AiSuggestionParentGroupContext.d.ts +13 -0
  16. package/dist/cjs/AiSuggestionParent/context/AiSuggestionParentGroupContext.js +25 -0
  17. package/dist/cjs/AiSuggestionParent/index.d.ts +4 -0
  18. package/dist/cjs/AiSuggestionParent/index.js +24 -0
  19. package/dist/cjs/AiSuggestionParent/styles.module.css +156 -0
  20. package/dist/cjs/AiSuggestionParent/types.d.ts +44 -0
  21. package/dist/cjs/AiSuggestionParent/types.js +2 -0
  22. package/dist/cjs/AiSuggestionParent/utils/hasExclusiveParentGroup.d.ts +2 -0
  23. package/dist/cjs/AiSuggestionParent/utils/hasExclusiveParentGroup.js +7 -0
  24. package/dist/cjs/AiSuggestionParent/utils/itemGuards.d.ts +3 -0
  25. package/dist/cjs/AiSuggestionParent/utils/itemGuards.js +11 -0
  26. package/dist/cjs/AiSuggestionSimple/AiSuggestionSimple.d.ts +18 -0
  27. package/dist/cjs/AiSuggestionSimple/AiSuggestionSimple.js +17 -0
  28. package/dist/cjs/AiSuggestionSimple/constants.d.ts +18 -0
  29. package/dist/cjs/AiSuggestionSimple/constants.js +21 -0
  30. package/dist/cjs/AiSuggestionSimple/index.d.ts +3 -0
  31. package/dist/cjs/AiSuggestionSimple/index.js +24 -0
  32. package/dist/cjs/AiSuggestionSimple/styles.module.css +138 -0
  33. package/dist/cjs/AiSuggestionSimple/types.d.ts +5 -0
  34. package/dist/cjs/AiSuggestionSimple/types.js +2 -0
  35. package/dist/cjs/AiSuggestionSimple/utils.d.ts +2 -0
  36. package/dist/cjs/AiSuggestionSimple/utils.js +7 -0
  37. package/dist/cjs/index.d.ts +2 -0
  38. package/dist/cjs/index.js +18 -0
  39. package/dist/cjs/package.json +3 -0
  40. package/dist/cjs/style.css +317 -0
  41. package/dist/esm/AiSuggestionParent/AiSuggestionParent.d.ts +7 -0
  42. package/dist/esm/AiSuggestionParent/AiSuggestionParent.js +101 -0
  43. package/dist/esm/AiSuggestionParent/AiSuggestionParentChip.d.ts +26 -0
  44. package/dist/esm/AiSuggestionParent/AiSuggestionParentChip.js +18 -0
  45. package/dist/esm/AiSuggestionParent/animation/AnimatedExpandableItem.d.ts +12 -0
  46. package/dist/esm/AiSuggestionParent/animation/AnimatedExpandableItem.js +17 -0
  47. package/dist/esm/AiSuggestionParent/animation/constants.d.ts +33 -0
  48. package/dist/esm/AiSuggestionParent/animation/constants.js +63 -0
  49. package/dist/esm/AiSuggestionParent/animation/styles.module.css +23 -0
  50. package/dist/esm/AiSuggestionParent/constants.d.ts +9 -0
  51. package/dist/esm/AiSuggestionParent/constants.js +9 -0
  52. package/dist/esm/AiSuggestionParent/context/AiSuggestionParentGroupContext.d.ts +13 -0
  53. package/dist/esm/AiSuggestionParent/context/AiSuggestionParentGroupContext.js +21 -0
  54. package/dist/esm/AiSuggestionParent/index.d.ts +4 -0
  55. package/dist/esm/AiSuggestionParent/index.js +4 -0
  56. package/dist/esm/AiSuggestionParent/styles.module.css +156 -0
  57. package/dist/esm/AiSuggestionParent/types.d.ts +44 -0
  58. package/dist/esm/AiSuggestionParent/types.js +1 -0
  59. package/dist/esm/AiSuggestionParent/utils/hasExclusiveParentGroup.d.ts +2 -0
  60. package/dist/esm/AiSuggestionParent/utils/hasExclusiveParentGroup.js +4 -0
  61. package/dist/esm/AiSuggestionParent/utils/itemGuards.d.ts +3 -0
  62. package/dist/esm/AiSuggestionParent/utils/itemGuards.js +7 -0
  63. package/dist/esm/AiSuggestionSimple/AiSuggestionSimple.d.ts +18 -0
  64. package/dist/esm/AiSuggestionSimple/AiSuggestionSimple.js +11 -0
  65. package/dist/esm/AiSuggestionSimple/constants.d.ts +18 -0
  66. package/dist/esm/AiSuggestionSimple/constants.js +18 -0
  67. package/dist/esm/AiSuggestionSimple/index.d.ts +3 -0
  68. package/dist/esm/AiSuggestionSimple/index.js +3 -0
  69. package/dist/esm/AiSuggestionSimple/styles.module.css +138 -0
  70. package/dist/esm/AiSuggestionSimple/types.d.ts +5 -0
  71. package/dist/esm/AiSuggestionSimple/types.js +1 -0
  72. package/dist/esm/AiSuggestionSimple/utils.d.ts +2 -0
  73. package/dist/esm/AiSuggestionSimple/utils.js +4 -0
  74. package/dist/esm/index.d.ts +2 -0
  75. package/dist/esm/index.js +2 -0
  76. package/dist/esm/style.css +317 -0
  77. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  78. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  79. package/package.json +45 -0
  80. package/src/AiSuggestionParent/AiSuggestionParent.tsx +257 -0
  81. package/src/AiSuggestionParent/AiSuggestionParentChip.tsx +79 -0
  82. package/src/AiSuggestionParent/animation/AnimatedExpandableItem.tsx +53 -0
  83. package/src/AiSuggestionParent/animation/constants.ts +77 -0
  84. package/src/AiSuggestionParent/animation/styles.module.scss +25 -0
  85. package/src/AiSuggestionParent/constants.ts +10 -0
  86. package/src/AiSuggestionParent/context/AiSuggestionParentGroupContext.tsx +44 -0
  87. package/src/AiSuggestionParent/index.ts +4 -0
  88. package/src/AiSuggestionParent/styles.module.scss +197 -0
  89. package/src/AiSuggestionParent/types.ts +49 -0
  90. package/src/AiSuggestionParent/utils/hasExclusiveParentGroup.ts +6 -0
  91. package/src/AiSuggestionParent/utils/itemGuards.ts +10 -0
  92. package/src/AiSuggestionSimple/AiSuggestionSimple.tsx +72 -0
  93. package/src/AiSuggestionSimple/constants.ts +21 -0
  94. package/src/AiSuggestionSimple/index.ts +3 -0
  95. package/src/AiSuggestionSimple/styles.module.scss +166 -0
  96. package/src/AiSuggestionSimple/types.ts +7 -0
  97. package/src/AiSuggestionSimple/utils.ts +6 -0
  98. 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,10 @@
1
+ export const CHILD_TYPE = {
2
+ Suggestion: 'suggestion',
3
+ Parent: 'parent',
4
+ } as const;
5
+
6
+ export const TEST_IDS = {
7
+ root: 'ai-suggestion-parent',
8
+ trigger: 'ai-suggestion-parent__trigger',
9
+ chevron: 'ai-suggestion-parent__chevron',
10
+ } as const;
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export * from './AiSuggestionParent';
2
+ export { AiSuggestionParentChip } from './AiSuggestionParentChip';
3
+ export { CHILD_TYPE, TEST_IDS as AI_SUGGESTION_PARENT_TEST_IDS } from './constants';
4
+ export * from './types';