@keboola/design 1.3.1 → 2.0.1
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/dist/icons.cjs +32 -0
- package/dist/icons.js +1 -1
- package/dist/index.cjs +507 -103
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +495 -106
- package/dist/index.js.map +1 -1
- package/dist/markdown-editor.cjs +78 -42
- package/dist/markdown-editor.cjs.map +1 -1
- package/dist/markdown-editor.js +78 -42
- package/dist/markdown-editor.js.map +1 -1
- package/dist/mermaid.cjs +32 -9
- package/dist/mermaid.cjs.map +1 -1
- package/dist/mermaid.js +32 -9
- package/dist/mermaid.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -6
- package/src/blocks/AIKit/PlanApproval.tsx +29 -12
- package/src/blocks/AIKit/PromptInput.test.tsx +53 -0
- package/src/blocks/AIKit/PromptInput.tsx +1 -1
- package/src/blocks/AIKit/Question.tsx +39 -11
- package/src/blocks/AIKit/SuggestedPrompt.stories.tsx +2 -8
- package/src/blocks/EmptyState/EmptyState.stories.tsx +5 -5
- package/src/blocks/EmptyState/EmptyState.tsx +2 -2
- package/src/blocks/ErrorState/ErrorState.stories.tsx +6 -6
- package/src/blocks/ErrorState/ErrorState.tsx +18 -3
- package/src/blocks/InputOtp/InputOtp.stories.tsx +214 -0
- package/src/blocks/InputOtp/InputOtp.test.tsx +162 -0
- package/src/blocks/InputOtp/InputOtp.tsx +62 -0
- package/src/blocks/InputOtp/InputOtpGroup.tsx +13 -0
- package/src/blocks/InputOtp/InputOtpSeparator.tsx +23 -0
- package/src/blocks/InputOtp/InputOtpSlot.styles.ts +52 -0
- package/src/blocks/InputOtp/InputOtpSlot.tsx +44 -0
- package/src/blocks/InputOtp/context.ts +23 -0
- package/src/blocks/InputOtp/index.ts +23 -0
- package/src/blocks/PageHeader/AGENTS.md +1 -1
- package/src/blocks/PageHeader/PageHeader.stories.tsx +6 -3
- package/src/blocks/PageHeader/PageHeaderIndex.tsx +2 -2
- package/src/blocks/PageLayout/AGENTS.md +3 -3
- package/src/blocks/PageLayout/PageLayout.stories.tsx +7 -4
- package/src/blocks/PageLayout/PageLayout.tsx +1 -18
- package/src/blocks/PageToolbar/PageToolbar.stories.tsx +79 -74
- package/src/blocks/PageToolbar/PageToolbar.tsx +45 -4
- package/src/blocks/PageToolbar/PageToolbarToken.tsx +61 -0
- package/src/blocks/SearchInput/SearchInput.tsx +2 -2
- package/src/components/Alert/AlertDescription.tsx +5 -0
- package/src/components/Alert/AlertIcon.tsx +1 -1
- package/src/components/Badge/Badge.stories.tsx +3 -5
- package/src/components/Badge/BadgeAddon.tsx +1 -1
- package/src/components/Badge/BadgeButton.tsx +1 -1
- package/src/components/Breadcrumb/Breadcrumb.stories.tsx +4 -4
- package/src/components/Breadcrumb/BreadcrumbLink.tsx +1 -1
- package/src/components/Breadcrumb/BreadcrumbSeparator.tsx +1 -1
- package/src/components/Button/ButtonInline.stories.tsx +3 -3
- package/src/components/Button/ButtonInline.tsx +4 -0
- package/src/components/Button/styles.ts +33 -10
- package/src/components/Card/Card.stories.tsx +2 -2
- package/src/components/Combobox/ComboboxItemIndicator.tsx +1 -1
- package/src/components/Command/CommandItem/CommandItem.tsx +1 -1
- package/src/components/DropdownMenu/DropdownMenuItemIndicator.tsx +4 -4
- package/src/components/DropdownMenu/ItemIcon.tsx +1 -1
- package/src/components/FormGroup/FormGroup.stories.tsx +2 -2
- package/src/components/HoverCard/HoverCardContent.tsx +5 -0
- package/src/components/Input/Input.stories.tsx +2 -2
- package/src/components/InputGroup/InputGroup.stories.tsx +4 -5
- package/src/components/InputGroup/InputGroup.tsx +1 -1
- package/src/components/InputGroup/InputGroupAddon.tsx +35 -24
- package/src/components/Kbd/Kbd.stories.tsx +2 -2
- package/src/components/Label/Label.stories.tsx +2 -2
- package/src/components/Modal/ModalBody.tsx +5 -0
- package/src/components/Modal/ModalIcon.tsx +1 -1
- package/src/components/Navbar/Navbar.stories.tsx +4 -5
- package/src/components/Navbar/NavbarMenuItemAdornment.tsx +2 -2
- package/src/components/Pagination/Pagination.stories.tsx +0 -1
- package/src/components/Pagination/PaginationEllipsis.tsx +1 -1
- package/src/components/Popover/PopoverContent.tsx +4 -0
- package/src/components/Select/SelectContent.tsx +1 -1
- package/src/components/Select/SelectTrigger.tsx +1 -1
- package/src/components/Separator/Separator.stories.tsx +19 -0
- package/src/components/Separator/Separator.test.tsx +39 -0
- package/src/components/Separator/Separator.tsx +42 -11
- package/src/components/Sidebar/Sidebar.stories.tsx +6 -5
- package/src/components/Sidebar/SidebarGroup/SidebarGroupAction.tsx +1 -1
- package/src/components/Sidebar/SidebarMenu/SidebarMenuAction.tsx +1 -1
- package/src/components/Sidebar/SidebarMenu/SidebarMenuIcon.tsx +1 -1
- package/src/components/Table/Table.stories.tsx +16 -16
- package/src/components/Timeline/Timeline.stories.tsx +8 -8
- package/src/components/Timeline/TimelineConnector.tsx +5 -1
- package/src/components/Toast/ToastDismiss.tsx +1 -1
- package/src/components/Toast/ToastRoot.tsx +1 -1
- package/src/components/Toggle/Toggle.tsx +1 -1
- package/src/components/ToggleGroup/ToggleGroup.stories.tsx +60 -0
- package/src/components/ToggleGroup/ToggleGroup.tsx +99 -0
- package/src/components/ToggleGroup/index.ts +1 -0
- package/src/components/Tooltip/Tooltip.stories.tsx +2 -2
- package/src/hooks/useAutosizeInput.stories.tsx +2 -2
- package/src/icons/index.ts +8 -0
- package/src/index.ts +2 -0
- package/src/patterns/FilterToolbar.stories.tsx +248 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keboola/design",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Keboola UI platform design system — shadcn-aligned React primitives, Keboola-branded blocks, and the form bindings built on TanStack Form.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -74,21 +74,21 @@
|
|
|
74
74
|
"@testing-library/user-event": "^14.6.1",
|
|
75
75
|
"@types/react": "^19.2.15",
|
|
76
76
|
"@vitejs/plugin-react": "^6.0.0",
|
|
77
|
-
"@vitest/browser": "4.1.
|
|
77
|
+
"@vitest/browser": "4.1.8",
|
|
78
78
|
"@vitest/browser-playwright": "4.1.2",
|
|
79
79
|
"eslint-plugin-storybook": "^10.3.5",
|
|
80
80
|
"oxfmt": "^0.44.0",
|
|
81
81
|
"oxlint": "^1.56.0",
|
|
82
|
-
"playwright": "1.
|
|
82
|
+
"playwright": "1.60.0",
|
|
83
83
|
"storybook": "^10.2.19",
|
|
84
84
|
"tailwindcss": "^3.4.17",
|
|
85
85
|
"tsup": "^8.5.1",
|
|
86
86
|
"typescript": "^6.0.3",
|
|
87
|
-
"vite": "^8.0.
|
|
88
|
-
"vitest": "^4.1.
|
|
87
|
+
"vite": "^8.0.16",
|
|
88
|
+
"vitest": "^4.1.8"
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"@awesome.me/kit-be8bcf7e5e": "1.0.
|
|
91
|
+
"@awesome.me/kit-be8bcf7e5e": "1.0.223",
|
|
92
92
|
"@base-ui/react": "1.5.0",
|
|
93
93
|
"@codemirror/autocomplete": "^6.20.1",
|
|
94
94
|
"@codemirror/commands": "^6.10.3",
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
"@radix-ui/react-switch": "^1.2.6",
|
|
133
133
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
134
134
|
"@radix-ui/react-toggle": "^1.1.10",
|
|
135
|
+
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
135
136
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
136
137
|
"@tanstack/react-form": "^1.29.1",
|
|
137
138
|
"@testing-library/jest-dom": "^6.8.0",
|
|
@@ -143,6 +144,7 @@
|
|
|
143
144
|
"clsx": "^2.1.1",
|
|
144
145
|
"cmdk": "^1.1.1",
|
|
145
146
|
"diff": "^9.0.0",
|
|
147
|
+
"input-otp": "^1.4.2",
|
|
146
148
|
"lexical": "^0.42.0",
|
|
147
149
|
"lucide-react": "^1.17.0",
|
|
148
150
|
"mermaid": "^11.15.0",
|
|
@@ -2,6 +2,7 @@ import { type FormEvent, type KeyboardEvent, type ReactNode, useState } from 're
|
|
|
2
2
|
|
|
3
3
|
import { Badge } from '../../components/Badge';
|
|
4
4
|
import { Button } from '../../components/Button';
|
|
5
|
+
import { Tooltip } from '../../components/Tooltip';
|
|
5
6
|
import { ChevronDown, Send, X } from '../../icons';
|
|
6
7
|
import { cn } from '../../utils';
|
|
7
8
|
import { Textarea } from '../Textarea';
|
|
@@ -23,6 +24,7 @@ type PlanApprovalProps = {
|
|
|
23
24
|
onRequestChanges?: (reason: string) => void;
|
|
24
25
|
onClose?: () => void;
|
|
25
26
|
isSubmitting?: boolean;
|
|
27
|
+
approveTooltip?: ReactNode;
|
|
26
28
|
|
|
27
29
|
pendingPillLabel?: string;
|
|
28
30
|
approvedPillLabel?: string;
|
|
@@ -123,6 +125,7 @@ export const PlanApproval = ({
|
|
|
123
125
|
onRequestChanges,
|
|
124
126
|
onClose,
|
|
125
127
|
isSubmitting = false,
|
|
128
|
+
approveTooltip,
|
|
126
129
|
|
|
127
130
|
pendingPillLabel = 'Ready for approval',
|
|
128
131
|
approvedPillLabel = 'Approved',
|
|
@@ -324,18 +327,32 @@ export const PlanApproval = ({
|
|
|
324
327
|
</Button>
|
|
325
328
|
)
|
|
326
329
|
)}
|
|
327
|
-
{onApprove &&
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
330
|
+
{onApprove &&
|
|
331
|
+
(approveTooltip ? (
|
|
332
|
+
<Tooltip tooltip={approveTooltip} placement="top" triggerAsChild>
|
|
333
|
+
<Button
|
|
334
|
+
argo
|
|
335
|
+
variant="primary"
|
|
336
|
+
size="small"
|
|
337
|
+
onClick={onApprove}
|
|
338
|
+
disabled={isSubmitting || isRequestingChanges}
|
|
339
|
+
data-testid="plan-approve-button"
|
|
340
|
+
>
|
|
341
|
+
{approveLabel}
|
|
342
|
+
</Button>
|
|
343
|
+
</Tooltip>
|
|
344
|
+
) : (
|
|
345
|
+
<Button
|
|
346
|
+
argo
|
|
347
|
+
variant="primary"
|
|
348
|
+
size="small"
|
|
349
|
+
onClick={onApprove}
|
|
350
|
+
disabled={isSubmitting || isRequestingChanges}
|
|
351
|
+
data-testid="plan-approve-button"
|
|
352
|
+
>
|
|
353
|
+
{approveLabel}
|
|
354
|
+
</Button>
|
|
355
|
+
))}
|
|
339
356
|
</div>
|
|
340
357
|
)}
|
|
341
358
|
</div>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { PromptInput } from './PromptInput';
|
|
5
|
+
|
|
6
|
+
describe('<PromptInput />', () => {
|
|
7
|
+
it('does not call onAbort when Enter is pressed during streaming', () => {
|
|
8
|
+
const onAbort = vi.fn();
|
|
9
|
+
const onSend = vi.fn();
|
|
10
|
+
|
|
11
|
+
render(<PromptInput status="streaming" value="hello" onSend={onSend} onAbort={onAbort} />);
|
|
12
|
+
|
|
13
|
+
const textarea = screen.getByRole('textbox');
|
|
14
|
+
fireEvent.keyDown(textarea, { key: 'Enter' });
|
|
15
|
+
|
|
16
|
+
expect(onAbort).not.toHaveBeenCalled();
|
|
17
|
+
expect(onSend).not.toHaveBeenCalled();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('calls onAbort when the stop button is clicked during streaming', () => {
|
|
21
|
+
const onAbort = vi.fn();
|
|
22
|
+
|
|
23
|
+
render(<PromptInput status="streaming" value="" onAbort={onAbort} />);
|
|
24
|
+
|
|
25
|
+
const form = screen.getByTestId('prompt-input');
|
|
26
|
+
const button = form.querySelector('button[type="submit"]')!;
|
|
27
|
+
fireEvent.click(button);
|
|
28
|
+
|
|
29
|
+
expect(onAbort).toHaveBeenCalledOnce();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('calls onSend when Enter is pressed in ready state', () => {
|
|
33
|
+
const onSend = vi.fn();
|
|
34
|
+
|
|
35
|
+
render(<PromptInput status="ready" value="hello" onSend={onSend} />);
|
|
36
|
+
|
|
37
|
+
const textarea = screen.getByRole('textbox');
|
|
38
|
+
fireEvent.keyDown(textarea, { key: 'Enter' });
|
|
39
|
+
|
|
40
|
+
expect(onSend).toHaveBeenCalledWith('hello');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('calls onContinue when Enter is pressed in interrupted state', () => {
|
|
44
|
+
const onContinue = vi.fn();
|
|
45
|
+
|
|
46
|
+
render(<PromptInput status="interrupted" value="" onContinue={onContinue} />);
|
|
47
|
+
|
|
48
|
+
const textarea = screen.getByRole('textbox');
|
|
49
|
+
fireEvent.keyDown(textarea, { key: 'Enter' });
|
|
50
|
+
|
|
51
|
+
expect(onContinue).toHaveBeenCalledOnce();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -138,7 +138,7 @@ export const PromptInput = ({
|
|
|
138
138
|
const handleOnKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
|
|
139
139
|
if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
|
140
140
|
event.preventDefault();
|
|
141
|
-
handleSubmit();
|
|
141
|
+
if (status !== 'streaming') handleSubmit();
|
|
142
142
|
}
|
|
143
143
|
};
|
|
144
144
|
|
|
@@ -4,6 +4,7 @@ import { Button } from '../../components/Button';
|
|
|
4
4
|
import { Checkbox } from '../../components/Checkbox';
|
|
5
5
|
import { TextInput } from '../../components/Input';
|
|
6
6
|
import { Progress } from '../../components/Progress';
|
|
7
|
+
import { Tooltip } from '../../components/Tooltip';
|
|
7
8
|
import { ChevronLeft, ChevronRight, X } from '../../icons';
|
|
8
9
|
import { cn } from '../../utils';
|
|
9
10
|
|
|
@@ -23,12 +24,14 @@ type Props = {
|
|
|
23
24
|
onSelectionChange?: (selectedIds: readonly string[]) => void;
|
|
24
25
|
customAnswer?: string;
|
|
25
26
|
onCustomAnswerChange?: (value: string) => void;
|
|
27
|
+
onCustomAnswerConfirm?: () => void;
|
|
26
28
|
showCustomAnswer?: boolean;
|
|
27
29
|
customAnswerPlaceholder?: string;
|
|
28
30
|
showSkip?: boolean;
|
|
29
31
|
onSkip?: () => void;
|
|
30
32
|
onClose?: () => void;
|
|
31
33
|
onNext?: () => void;
|
|
34
|
+
submitTooltip?: ReactNode;
|
|
32
35
|
onPrevious?: () => void;
|
|
33
36
|
currentStep?: number;
|
|
34
37
|
totalSteps?: number;
|
|
@@ -52,12 +55,14 @@ export const Question = ({
|
|
|
52
55
|
onSelectionChange,
|
|
53
56
|
customAnswer = '',
|
|
54
57
|
onCustomAnswerChange,
|
|
58
|
+
onCustomAnswerConfirm,
|
|
55
59
|
showCustomAnswer = true,
|
|
56
60
|
customAnswerPlaceholder = 'Type your answer…',
|
|
57
61
|
showSkip = true,
|
|
58
62
|
onSkip,
|
|
59
63
|
onClose,
|
|
60
64
|
onNext,
|
|
65
|
+
submitTooltip,
|
|
61
66
|
onPrevious,
|
|
62
67
|
currentStep,
|
|
63
68
|
totalSteps,
|
|
@@ -238,6 +243,16 @@ export const Question = ({
|
|
|
238
243
|
value={customAnswer}
|
|
239
244
|
onValueChange={(value) => onCustomAnswerChange?.(value)}
|
|
240
245
|
onFocus={handleCustomFocus}
|
|
246
|
+
onKeyDown={
|
|
247
|
+
onCustomAnswerConfirm
|
|
248
|
+
? (e: KeyboardEvent<HTMLInputElement>) => {
|
|
249
|
+
if (e.key === 'Enter' && !e.shiftKey && !(e.metaKey || e.ctrlKey)) {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
onCustomAnswerConfirm();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
: undefined
|
|
255
|
+
}
|
|
241
256
|
placeholder={customAnswerPlaceholder}
|
|
242
257
|
sizing="small"
|
|
243
258
|
testId="question-custom"
|
|
@@ -302,17 +317,30 @@ export const Question = ({
|
|
|
302
317
|
Skip
|
|
303
318
|
</Button>
|
|
304
319
|
)}
|
|
305
|
-
{(multiSelect || isCustomSelected) &&
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
320
|
+
{(multiSelect || isCustomSelected) &&
|
|
321
|
+
(submitTooltip ? (
|
|
322
|
+
<Tooltip tooltip={submitTooltip} placement="top" triggerAsChild>
|
|
323
|
+
<Button
|
|
324
|
+
argo
|
|
325
|
+
variant="primary"
|
|
326
|
+
size="small"
|
|
327
|
+
onClick={onNext}
|
|
328
|
+
data-testid="question-next-button"
|
|
329
|
+
>
|
|
330
|
+
{isLastStep ? 'Submit' : 'Next'}
|
|
331
|
+
</Button>
|
|
332
|
+
</Tooltip>
|
|
333
|
+
) : (
|
|
334
|
+
<Button
|
|
335
|
+
argo
|
|
336
|
+
variant="primary"
|
|
337
|
+
size="small"
|
|
338
|
+
onClick={onNext}
|
|
339
|
+
data-testid="question-next-button"
|
|
340
|
+
>
|
|
341
|
+
{isLastStep ? 'Submit' : 'Next'}
|
|
342
|
+
</Button>
|
|
343
|
+
))}
|
|
316
344
|
</div>
|
|
317
345
|
</div>
|
|
318
346
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Play } from '../../icons';
|
|
4
4
|
|
|
5
5
|
import { SuggestedPrompt } from './SuggestedPrompt';
|
|
6
6
|
|
|
@@ -53,13 +53,7 @@ export const LongLabel: Story = {
|
|
|
53
53
|
export const WithCustomIcon: Story = {
|
|
54
54
|
args: {
|
|
55
55
|
label: 'Run a transformation',
|
|
56
|
-
icon:
|
|
57
|
-
<Icon
|
|
58
|
-
className="tw-size-4 tw-shrink-0 tw-basis-4 tw-text-secondary-500"
|
|
59
|
-
fixedWidth
|
|
60
|
-
icon="play"
|
|
61
|
-
/>
|
|
62
|
-
),
|
|
56
|
+
icon: <Play className="tw-size-4 tw-shrink-0 tw-basis-4 tw-text-secondary-500" />,
|
|
63
57
|
disabled: false,
|
|
64
58
|
},
|
|
65
59
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
|
|
3
3
|
import { Button } from '../../components/Button';
|
|
4
|
-
import {
|
|
4
|
+
import { Bell, List, Search, Table } from '../../icons';
|
|
5
5
|
|
|
6
6
|
import { EmptyState } from './EmptyState';
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ export const ListEmpty: Story = {
|
|
|
24
24
|
render: () => (
|
|
25
25
|
<EmptyState>
|
|
26
26
|
<EmptyState.Icon>
|
|
27
|
-
<
|
|
27
|
+
<List />
|
|
28
28
|
</EmptyState.Icon>
|
|
29
29
|
<EmptyState.Title>No items yet</EmptyState.Title>
|
|
30
30
|
<EmptyState.Description>
|
|
@@ -42,7 +42,7 @@ export const TableEmpty: Story = {
|
|
|
42
42
|
render: () => (
|
|
43
43
|
<EmptyState>
|
|
44
44
|
<EmptyState.Icon>
|
|
45
|
-
<
|
|
45
|
+
<Table />
|
|
46
46
|
</EmptyState.Icon>
|
|
47
47
|
<EmptyState.Title>No rows found</EmptyState.Title>
|
|
48
48
|
<EmptyState.Description>
|
|
@@ -63,7 +63,7 @@ export const SearchNoResults: Story = {
|
|
|
63
63
|
render: () => (
|
|
64
64
|
<EmptyState>
|
|
65
65
|
<EmptyState.Icon>
|
|
66
|
-
<
|
|
66
|
+
<Search />
|
|
67
67
|
</EmptyState.Icon>
|
|
68
68
|
<EmptyState.Title>No results found</EmptyState.Title>
|
|
69
69
|
<EmptyState.Description>
|
|
@@ -84,7 +84,7 @@ export const CompactListEmpty: Story = {
|
|
|
84
84
|
render: () => (
|
|
85
85
|
<EmptyState variant="compact">
|
|
86
86
|
<EmptyState.Icon variant="compact">
|
|
87
|
-
<
|
|
87
|
+
<Bell />
|
|
88
88
|
</EmptyState.Icon>
|
|
89
89
|
<EmptyState.Title variant="compact">No notifications</EmptyState.Title>
|
|
90
90
|
<EmptyState.Description variant="compact">You're all caught up!</EmptyState.Description>
|
|
@@ -19,8 +19,8 @@ const emptyStateIconClassnames = cva(
|
|
|
19
19
|
{
|
|
20
20
|
variants: {
|
|
21
21
|
variant: {
|
|
22
|
-
default: ['tw-size-16', 'tw-text-4xl'],
|
|
23
|
-
compact: ['tw-size-10', 'tw-text-2xl'],
|
|
22
|
+
default: ['tw-size-16', 'tw-text-4xl', '[&_svg:not([class*="tw-size-"])]:tw-size-10'],
|
|
23
|
+
compact: ['tw-size-10', 'tw-text-2xl', '[&_svg:not([class*="tw-size-"])]:tw-size-6'],
|
|
24
24
|
},
|
|
25
25
|
},
|
|
26
26
|
defaultVariants: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
|
|
3
3
|
import { Button } from '../../components/Button';
|
|
4
|
-
import {
|
|
4
|
+
import { AlertCircle, AlertTriangle, Bug, Unlink, XCircle } from '../../icons';
|
|
5
5
|
|
|
6
6
|
import { ErrorState } from './ErrorState';
|
|
7
7
|
|
|
@@ -24,7 +24,7 @@ export const ClientError: Story = {
|
|
|
24
24
|
render: () => (
|
|
25
25
|
<ErrorState>
|
|
26
26
|
<ErrorState.Icon>
|
|
27
|
-
<
|
|
27
|
+
<AlertCircle />
|
|
28
28
|
</ErrorState.Icon>
|
|
29
29
|
<ErrorState.Title>Page not found</ErrorState.Title>
|
|
30
30
|
<ErrorState.Description>
|
|
@@ -46,7 +46,7 @@ export const ServerError: Story = {
|
|
|
46
46
|
render: () => (
|
|
47
47
|
<ErrorState>
|
|
48
48
|
<ErrorState.Icon>
|
|
49
|
-
<
|
|
49
|
+
<Bug />
|
|
50
50
|
</ErrorState.Icon>
|
|
51
51
|
<ErrorState.Title>Something went wrong</ErrorState.Title>
|
|
52
52
|
<ErrorState.Description>
|
|
@@ -68,7 +68,7 @@ export const NetworkFailure: Story = {
|
|
|
68
68
|
render: () => (
|
|
69
69
|
<ErrorState>
|
|
70
70
|
<ErrorState.Icon>
|
|
71
|
-
<
|
|
71
|
+
<Unlink />
|
|
72
72
|
</ErrorState.Icon>
|
|
73
73
|
<ErrorState.Title>No connection</ErrorState.Title>
|
|
74
74
|
<ErrorState.Description>
|
|
@@ -86,7 +86,7 @@ export const Destructive: Story = {
|
|
|
86
86
|
render: () => (
|
|
87
87
|
<ErrorState variant="destructive">
|
|
88
88
|
<ErrorState.Icon variant="destructive">
|
|
89
|
-
<
|
|
89
|
+
<AlertTriangle />
|
|
90
90
|
</ErrorState.Icon>
|
|
91
91
|
<ErrorState.Title variant="destructive">Critical error</ErrorState.Title>
|
|
92
92
|
<ErrorState.Description variant="destructive">
|
|
@@ -109,7 +109,7 @@ export const CompactServerError: Story = {
|
|
|
109
109
|
render: () => (
|
|
110
110
|
<ErrorState variant="compact">
|
|
111
111
|
<ErrorState.Icon variant="compact">
|
|
112
|
-
<
|
|
112
|
+
<XCircle />
|
|
113
113
|
</ErrorState.Icon>
|
|
114
114
|
<ErrorState.Title variant="compact">Failed to load</ErrorState.Title>
|
|
115
115
|
<ErrorState.Description variant="compact">
|
|
@@ -18,9 +18,24 @@ const errorStateClassnames = cva(['tw-flex', 'tw-flex-col', 'tw-items-center', '
|
|
|
18
18
|
const errorStateIconClassnames = cva(['tw-flex', 'tw-items-center', 'tw-justify-center'], {
|
|
19
19
|
variants: {
|
|
20
20
|
variant: {
|
|
21
|
-
default: [
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
default: [
|
|
22
|
+
'tw-size-16',
|
|
23
|
+
'tw-text-4xl',
|
|
24
|
+
'[&_svg:not([class*="tw-size-"])]:tw-size-10',
|
|
25
|
+
'tw-text-warning-500',
|
|
26
|
+
],
|
|
27
|
+
compact: [
|
|
28
|
+
'tw-size-10',
|
|
29
|
+
'tw-text-2xl',
|
|
30
|
+
'[&_svg:not([class*="tw-size-"])]:tw-size-6',
|
|
31
|
+
'tw-text-warning-500',
|
|
32
|
+
],
|
|
33
|
+
destructive: [
|
|
34
|
+
'tw-size-16',
|
|
35
|
+
'tw-text-4xl',
|
|
36
|
+
'[&_svg:not([class*="tw-size-"])]:tw-size-10',
|
|
37
|
+
'tw-text-error-500',
|
|
38
|
+
],
|
|
24
39
|
},
|
|
25
40
|
},
|
|
26
41
|
defaultVariants: {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { ComponentProps, FormEventHandler } from 'react';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
+
import { action } from 'storybook/actions';
|
|
5
|
+
|
|
6
|
+
import { Button } from '../../components/Button';
|
|
7
|
+
import { FormGroup } from '../../components/FormGroup';
|
|
8
|
+
import { Label } from '../../components/Label';
|
|
9
|
+
|
|
10
|
+
import { REGEXP_ONLY_DIGITS_AND_CHARS } from './index';
|
|
11
|
+
import { InputOtp } from './InputOtp';
|
|
12
|
+
|
|
13
|
+
type Story = StoryObj<typeof InputOtp>;
|
|
14
|
+
type InputOtpProps = ComponentProps<typeof InputOtp>;
|
|
15
|
+
type Sizing = NonNullable<InputOtpProps['sizing']>;
|
|
16
|
+
type State = NonNullable<InputOtpProps['state']>;
|
|
17
|
+
|
|
18
|
+
const sizings: Sizing[] = ['sm', 'md', 'lg'];
|
|
19
|
+
const states: State[] = ['default', 'error'];
|
|
20
|
+
|
|
21
|
+
const ContiguousSlots = ({ length }: { length: number }) => (
|
|
22
|
+
<InputOtp.Group>
|
|
23
|
+
{Array.from({ length }, (_, index) => (
|
|
24
|
+
<InputOtp.Slot key={index} index={index} />
|
|
25
|
+
))}
|
|
26
|
+
</InputOtp.Group>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const SplitSlots = ({ length }: { length: number }) => {
|
|
30
|
+
const half = Math.ceil(length / 2);
|
|
31
|
+
return (
|
|
32
|
+
<>
|
|
33
|
+
<InputOtp.Group>
|
|
34
|
+
{Array.from({ length: half }, (_, index) => (
|
|
35
|
+
<InputOtp.Slot key={index} index={index} />
|
|
36
|
+
))}
|
|
37
|
+
</InputOtp.Group>
|
|
38
|
+
{length > half && (
|
|
39
|
+
<>
|
|
40
|
+
<InputOtp.Separator />
|
|
41
|
+
<InputOtp.Group>
|
|
42
|
+
{Array.from({ length: length - half }, (_, i) => (
|
|
43
|
+
<InputOtp.Slot key={half + i} index={half + i} />
|
|
44
|
+
))}
|
|
45
|
+
</InputOtp.Group>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default {
|
|
53
|
+
component: InputOtp,
|
|
54
|
+
parameters: {
|
|
55
|
+
docs: {
|
|
56
|
+
description: {
|
|
57
|
+
component: [
|
|
58
|
+
'OTP / verification-code input. Wraps the headless `input-otp` primitive with a',
|
|
59
|
+
'Keboola-themed slot, group, and separator using semantic Tailwind tokens. Paste,',
|
|
60
|
+
'SMS autofill, and per-character focus management are inherited from `input-otp`.',
|
|
61
|
+
'',
|
|
62
|
+
'**When to use:** entering a short fixed-length code — MFA / 2FA, email confirmation,',
|
|
63
|
+
'sudo-mode step-up, invite acceptance.',
|
|
64
|
+
'',
|
|
65
|
+
'**When NOT to use:** free-form text → `TextInput` / `FormGroup.TextInput`;',
|
|
66
|
+
'variable-length secrets → `PasswordInput`; numeric values → `NumberInput`.',
|
|
67
|
+
'',
|
|
68
|
+
'**Composition:** wrap slots in `InputOtp.Group`s, optionally separated by',
|
|
69
|
+
'`InputOtp.Separator`. For full control over slot rendering, call',
|
|
70
|
+
'`useInputOtpContext()` inside a custom child.',
|
|
71
|
+
'',
|
|
72
|
+
'**Sizing:** `sm` / `md` / `lg` match InputGroup heights (≈ 32 / 36 / 40px) so an OTP',
|
|
73
|
+
'field can sit next to a regular text input without the row jumping.',
|
|
74
|
+
].join('\n'),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
args: {
|
|
79
|
+
maxLength: 6,
|
|
80
|
+
sizing: 'md',
|
|
81
|
+
state: 'default',
|
|
82
|
+
disabled: false,
|
|
83
|
+
autoFocus: false,
|
|
84
|
+
onComplete: action('onComplete'),
|
|
85
|
+
onChange: action('onChange'),
|
|
86
|
+
},
|
|
87
|
+
argTypes: {
|
|
88
|
+
sizing: {
|
|
89
|
+
description: 'Slot size',
|
|
90
|
+
type: { name: 'enum', value: sizings },
|
|
91
|
+
},
|
|
92
|
+
state: {
|
|
93
|
+
description: 'Validation state',
|
|
94
|
+
type: { name: 'enum', value: states },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
render: (args) => (
|
|
98
|
+
<InputOtp {...args}>
|
|
99
|
+
<ContiguousSlots length={args.maxLength} />
|
|
100
|
+
</InputOtp>
|
|
101
|
+
),
|
|
102
|
+
} satisfies Meta<typeof InputOtp>;
|
|
103
|
+
|
|
104
|
+
export const Default: Story = {};
|
|
105
|
+
|
|
106
|
+
export const WithSeparator: Story = {
|
|
107
|
+
render: (args) => (
|
|
108
|
+
<InputOtp {...args}>
|
|
109
|
+
<SplitSlots length={args.maxLength} />
|
|
110
|
+
</InputOtp>
|
|
111
|
+
),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const Sizing: Story = {
|
|
115
|
+
render: (args) => (
|
|
116
|
+
<div className="tw-flex tw-flex-col tw-gap-6">
|
|
117
|
+
{sizings.map((sizing) => (
|
|
118
|
+
<div key={sizing} className="tw-flex tw-flex-col tw-gap-1">
|
|
119
|
+
<span className="tw-text-xs tw-text-muted-foreground">{sizing}</span>
|
|
120
|
+
<InputOtp {...args} sizing={sizing}>
|
|
121
|
+
<ContiguousSlots length={args.maxLength} />
|
|
122
|
+
</InputOtp>
|
|
123
|
+
</div>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const States: Story = {
|
|
130
|
+
render: (args) => (
|
|
131
|
+
<div className="tw-flex tw-flex-col tw-gap-6">
|
|
132
|
+
<div className="tw-flex tw-flex-col tw-gap-1">
|
|
133
|
+
<span className="tw-text-xs tw-text-muted-foreground">default</span>
|
|
134
|
+
<InputOtp {...args} state="default">
|
|
135
|
+
<ContiguousSlots length={args.maxLength} />
|
|
136
|
+
</InputOtp>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="tw-flex tw-flex-col tw-gap-1">
|
|
139
|
+
<span className="tw-text-xs tw-text-muted-foreground">error</span>
|
|
140
|
+
<InputOtp {...args} state="error" defaultValue="123">
|
|
141
|
+
<ContiguousSlots length={args.maxLength} />
|
|
142
|
+
</InputOtp>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="tw-flex tw-flex-col tw-gap-1">
|
|
145
|
+
<span className="tw-text-xs tw-text-muted-foreground">disabled</span>
|
|
146
|
+
<InputOtp {...args} disabled defaultValue="42">
|
|
147
|
+
<ContiguousSlots length={args.maxLength} />
|
|
148
|
+
</InputOtp>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const Alphanumeric: Story = {
|
|
155
|
+
args: {
|
|
156
|
+
pattern: REGEXP_ONLY_DIGITS_AND_CHARS,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const ControlledDemo = (props: InputOtpProps) => {
|
|
161
|
+
const [value, setValue] = useState('');
|
|
162
|
+
return (
|
|
163
|
+
<div className="tw-flex tw-flex-col tw-gap-3">
|
|
164
|
+
<InputOtp
|
|
165
|
+
{...props}
|
|
166
|
+
value={value}
|
|
167
|
+
onChange={(next) => {
|
|
168
|
+
action('onChange')(next);
|
|
169
|
+
setValue(next);
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<SplitSlots length={props.maxLength} />
|
|
173
|
+
</InputOtp>
|
|
174
|
+
<span className="tw-text-sm tw-text-muted-foreground">Entered: {value || '(empty)'}</span>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const Controlled: Story = {
|
|
180
|
+
render: ControlledDemo,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const UncontrolledDemo = (props: InputOtpProps) => {
|
|
184
|
+
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
const values = new FormData(event.currentTarget);
|
|
187
|
+
action('onSubmit')(Object.fromEntries(values.entries()));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<form className="tw-flex tw-items-end tw-gap-3" onSubmit={handleSubmit}>
|
|
192
|
+
<InputOtp {...props} name="code" defaultValue="" onComplete={action('onComplete')}>
|
|
193
|
+
<SplitSlots length={props.maxLength} />
|
|
194
|
+
</InputOtp>
|
|
195
|
+
<Button type="submit">Verify</Button>
|
|
196
|
+
</form>
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const Uncontrolled: Story = {
|
|
201
|
+
render: UncontrolledDemo,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const WithLabel: Story = {
|
|
205
|
+
render: (args) => (
|
|
206
|
+
<FormGroup className="tw-max-w-sm">
|
|
207
|
+
<Label>Verification code</Label>
|
|
208
|
+
<InputOtp {...args}>
|
|
209
|
+
<SplitSlots length={args.maxLength} />
|
|
210
|
+
</InputOtp>
|
|
211
|
+
<FormGroup.Help>We just sent a 6-digit code to your authenticator app.</FormGroup.Help>
|
|
212
|
+
</FormGroup>
|
|
213
|
+
),
|
|
214
|
+
};
|