@keboola/design 1.3.0 → 2.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 (69) hide show
  1. package/dist/icons.cjs +24 -0
  2. package/dist/icons.js +1 -1
  3. package/dist/index.cjs +278 -76
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +278 -78
  6. package/dist/index.js.map +1 -1
  7. package/dist/markdown-editor.cjs +39 -26
  8. package/dist/markdown-editor.cjs.map +1 -1
  9. package/dist/markdown-editor.js +39 -26
  10. package/dist/markdown-editor.js.map +1 -1
  11. package/dist/styles.css +1 -1
  12. package/package.json +4 -3
  13. package/src/blocks/AIKit/PlanApproval.tsx +29 -12
  14. package/src/blocks/AIKit/PromptInput.test.tsx +53 -0
  15. package/src/blocks/AIKit/PromptInput.tsx +1 -1
  16. package/src/blocks/AIKit/Question.tsx +39 -11
  17. package/src/blocks/AIKit/SuggestedPrompt.stories.tsx +2 -8
  18. package/src/blocks/EmptyState/EmptyState.stories.tsx +5 -5
  19. package/src/blocks/EmptyState/EmptyState.tsx +2 -2
  20. package/src/blocks/ErrorState/ErrorState.stories.tsx +6 -6
  21. package/src/blocks/ErrorState/ErrorState.tsx +3 -3
  22. package/src/blocks/MultiActionButtonGroup/MultiActionButton.tsx +2 -1
  23. package/src/blocks/PageHeader/AGENTS.md +1 -1
  24. package/src/blocks/PageHeader/PageHeader.stories.tsx +6 -3
  25. package/src/blocks/PageHeader/PageHeaderIndex.tsx +2 -2
  26. package/src/blocks/PageLayout/AGENTS.md +3 -3
  27. package/src/blocks/PageLayout/PageLayout.stories.tsx +7 -4
  28. package/src/blocks/PageLayout/PageLayout.tsx +1 -18
  29. package/src/blocks/PageToolbar/PageToolbar.stories.tsx +79 -74
  30. package/src/blocks/PageToolbar/PageToolbar.tsx +45 -4
  31. package/src/blocks/PageToolbar/PageToolbarToken.tsx +61 -0
  32. package/src/blocks/SearchInput/SearchInput.tsx +3 -3
  33. package/src/blocks/SearchInputWithNavigation/SearchInputWithNavigation.tsx +1 -1
  34. package/src/components/Alert/AlertDescription.tsx +5 -0
  35. package/src/components/Badge/Badge.stories.tsx +3 -5
  36. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +4 -4
  37. package/src/components/Breadcrumb/BreadcrumbLink.tsx +1 -1
  38. package/src/components/Button/ButtonInline.stories.tsx +3 -3
  39. package/src/components/Button/ButtonInline.tsx +4 -0
  40. package/src/components/Card/Card.stories.tsx +2 -2
  41. package/src/components/FormGroup/FormGroup.stories.tsx +2 -2
  42. package/src/components/HoverCard/HoverCardContent.tsx +5 -0
  43. package/src/components/Input/Input.stories.tsx +2 -2
  44. package/src/components/InputGroup/InputGroup.stories.tsx +4 -5
  45. package/src/components/InputGroup/InputGroup.tsx +1 -1
  46. package/src/components/InputGroup/InputGroupAddon.tsx +35 -24
  47. package/src/components/Kbd/Kbd.stories.tsx +2 -2
  48. package/src/components/Label/Label.stories.tsx +2 -2
  49. package/src/components/Modal/ModalBody.tsx +5 -0
  50. package/src/components/Navbar/Navbar.stories.tsx +4 -5
  51. package/src/components/Pagination/Pagination.stories.tsx +0 -1
  52. package/src/components/Popover/PopoverContent.tsx +4 -0
  53. package/src/components/Search/Search.tsx +2 -2
  54. package/src/components/Select/SelectTrigger.tsx +1 -1
  55. package/src/components/Separator/Separator.stories.tsx +19 -0
  56. package/src/components/Separator/Separator.test.tsx +39 -0
  57. package/src/components/Separator/Separator.tsx +42 -11
  58. package/src/components/Sidebar/Sidebar.stories.tsx +6 -5
  59. package/src/components/Table/Table.stories.tsx +16 -16
  60. package/src/components/Timeline/Timeline.stories.tsx +8 -8
  61. package/src/components/Timeline/TimelineConnector.tsx +1 -1
  62. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +60 -0
  63. package/src/components/ToggleGroup/ToggleGroup.tsx +99 -0
  64. package/src/components/ToggleGroup/index.ts +1 -0
  65. package/src/components/Tooltip/Tooltip.stories.tsx +2 -2
  66. package/src/hooks/useAutosizeInput.stories.tsx +2 -2
  67. package/src/icons/index.ts +6 -0
  68. package/src/index.ts +1 -0
  69. package/src/patterns/FilterToolbar.stories.tsx +248 -44
@@ -0,0 +1,99 @@
1
+ import type { ComponentProps, ElementRef, Ref } from 'react';
2
+ import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
3
+ import { cva, type VariantProps } from 'class-variance-authority';
4
+
5
+ import { cn } from '../../utils';
6
+
7
+ const rootVariants = cva(
8
+ [
9
+ 'tailwindcss-preflight',
10
+ 'tw-inline-flex',
11
+ 'tw-items-center',
12
+ 'tw-shrink-0',
13
+ 'tw-bg-muted',
14
+ 'tw-rounded-lg',
15
+ 'tw-p-[3px]',
16
+ 'tw-gap-0',
17
+ ],
18
+ {
19
+ defaultVariants: { size: 'medium' },
20
+ variants: {
21
+ size: {
22
+ small: ['tw-h-8'],
23
+ medium: ['tw-h-9'],
24
+ large: ['tw-h-10'],
25
+ },
26
+ },
27
+ },
28
+ );
29
+
30
+ const itemClasses = cn(
31
+ 'tailwindcss-preflight',
32
+ 'tw-inline-flex',
33
+ 'tw-items-center',
34
+ 'tw-justify-center',
35
+ 'tw-gap-2',
36
+ 'tw-shrink-0',
37
+ 'tw-whitespace-nowrap',
38
+
39
+ 'tw-h-full',
40
+ 'tw-rounded-md',
41
+ 'tw-px-2',
42
+ 'tw-py-1',
43
+
44
+ 'tw-bg-transparent',
45
+ 'tw-text-foreground',
46
+ 'tw-text-sm',
47
+ 'tw-font-medium',
48
+
49
+ 'tw-transition-colors',
50
+
51
+ 'disabled:tw-cursor-not-allowed',
52
+ 'disabled:tw-opacity-50',
53
+
54
+ '!tw-outline-none',
55
+ 'focus-visible:tw-ring-2',
56
+ 'focus-visible:tw-ring-primary/50',
57
+
58
+ // hover on non-pressed
59
+ 'not-disabled:[&[data-state=off]]:hover:tw-bg-background/60',
60
+
61
+ // pressed
62
+ 'data-[state=on]:tw-bg-background',
63
+ 'data-[state=on]:tw-shadow-[0_1px_2px_rgba(0,0,0,0.05),0_1px_1px_rgba(0,0,0,0.1)]',
64
+
65
+ '[&_svg]:tw-shrink-0',
66
+ '[&_svg]:tw-pointer-events-none',
67
+ "[&_svg:not([class*='tw-size-'])]:tw-size-4",
68
+ );
69
+
70
+ type RootProps = ComponentProps<typeof ToggleGroupPrimitive.Root> &
71
+ VariantProps<typeof rootVariants>;
72
+
73
+ /**
74
+ * Segmented row of mutually-exclusive (or multi-select) options. Built on `@radix-ui/react-toggle-group`.
75
+ * For `type="single"`, `onValueChange` receives `""` when the user deselects the active item.
76
+ */
77
+ const Root = ({
78
+ ref,
79
+ className,
80
+ size = 'medium',
81
+ ...props
82
+ }: RootProps & { ref?: Ref<ElementRef<'div'>> }) => (
83
+ <ToggleGroupPrimitive.Root
84
+ ref={ref}
85
+ {...props}
86
+ className={cn(rootVariants({ size, className }))}
87
+ />
88
+ );
89
+
90
+ type ItemProps = ComponentProps<typeof ToggleGroupPrimitive.Item>;
91
+
92
+ const Item = ({ ref, className, ...props }: ItemProps & { ref?: Ref<ElementRef<'button'>> }) => (
93
+ <ToggleGroupPrimitive.Item ref={ref} {...props} className={cn(itemClasses, className)} />
94
+ );
95
+
96
+ export const ToggleGroup = Object.assign(Root, {
97
+ /** Individual segment inside a `ToggleGroup`. `value` is required and must be unique within the group. */
98
+ Item,
99
+ });
@@ -0,0 +1 @@
1
+ export { ToggleGroup } from './ToggleGroup';
@@ -1,8 +1,8 @@
1
1
  import type { ComponentType } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
 
4
+ import { CircleHelp } from '../../icons';
4
5
  import { Button } from '../Button';
5
- import { Icon } from '../Icon';
6
6
 
7
7
  import { Tooltip, TooltipProvider } from './Tooltip';
8
8
 
@@ -53,7 +53,7 @@ export const StaticExplanatory: Story = {
53
53
  },
54
54
  render: (args) => (
55
55
  <Tooltip {...args}>
56
- <Icon icon="circle-question" className="tw-text-neutral-400" />
56
+ <CircleHelp className="tw-size-4 tw-text-neutral-400" />
57
57
  </Tooltip>
58
58
  ),
59
59
  parameters: {
@@ -1,9 +1,9 @@
1
1
  import { type ChangeEvent, useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
 
4
- import { Icon } from '../components/Icon';
5
4
  import { TextInput } from '../components/Input/TextInput/TextInput';
6
5
  import { InputGroup } from '../components/InputGroup';
6
+ import { Search } from '../icons';
7
7
 
8
8
  import { useAutosizeInput } from './useAutosizeInput';
9
9
 
@@ -102,7 +102,7 @@ export const WithInputGroup: Story = {
102
102
  return (
103
103
  <InputGroup className="!tw-inline-flex">
104
104
  <InputGroup.Addon align="inline-start">
105
- <Icon icon="magnifying-glass" className="tw-size-4 tw-text-neutral-400" />
105
+ <Search className="tw-size-4 tw-text-neutral-400" />
106
106
  </InputGroup.Addon>
107
107
  <InputGroup.Input
108
108
  ref={inputRef}
@@ -14,6 +14,7 @@ export {
14
14
  ArrowRight,
15
15
  ArrowRightLeft,
16
16
  ArrowUp,
17
+ ArrowUpAZ,
17
18
  ArrowUpRight,
18
19
  Ban,
19
20
  Bell,
@@ -99,6 +100,7 @@ export {
99
100
  Link,
100
101
  List,
101
102
  ListChecks,
103
+ ListFilter,
102
104
  ListOrdered,
103
105
  Loader2,
104
106
  Lock,
@@ -116,7 +118,11 @@ export {
116
118
  MoveHorizontal,
117
119
  MoveVertical,
118
120
  OctagonAlert,
121
+ PanelBottomClose,
122
+ PanelBottomOpen,
119
123
  PanelLeft,
124
+ PanelLeftClose,
125
+ PanelLeftOpen,
120
126
  PartyPopper,
121
127
  Pause,
122
128
  Pencil,
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export * from './components/ThemeProvider';
37
37
  export * from './components/Avatar';
38
38
  export * from './components/MarkedText';
39
39
  export * from './components/Toggle';
40
+ export * from './components/ToggleGroup';
40
41
  export * from './components/Timeline';
41
42
  export * from './components/Kbd';
42
43
  export * from './components/Collapsible';
@@ -1,48 +1,232 @@
1
1
  import { useMemo, useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
 
4
- import { Combobox, PageLayout, SearchInput } from '@keboola/design';
4
+ import {
5
+ Badge,
6
+ Button,
7
+ InputGroup,
8
+ PageLayout,
9
+ PageToolbar,
10
+ Select,
11
+ Separator,
12
+ ToggleGroup,
13
+ Tooltip,
14
+ } from '@keboola/design';
15
+ import {
16
+ ChevronsUpDown,
17
+ Info,
18
+ LayoutGrid,
19
+ List,
20
+ ListFilter,
21
+ Plus,
22
+ RefreshCw,
23
+ Search,
24
+ X,
25
+ } from '@keboola/design/icons';
5
26
 
6
- type Row = { id: string; name: string; type: 'table' | 'file' };
27
+ type Row = {
28
+ id: string;
29
+ name: string;
30
+ type: 'table' | 'file';
31
+ status: 'scheduled' | 'failed' | 'succeeded';
32
+ };
33
+
34
+ type FilterKey = 'scheduled' | 'failed' | 'succeeded';
35
+
36
+ type ViewMode = 'list' | 'grid';
37
+
38
+ type SortKey = 'name-asc' | 'name-desc' | 'recent';
39
+
40
+ const SORT_LABEL: Record<SortKey, string> = {
41
+ 'name-asc': 'Name A to Z',
42
+ 'name-desc': 'Name Z to A',
43
+ recent: 'Recently updated',
44
+ };
45
+
46
+ const FILTER_LABEL: Record<FilterKey, string> = {
47
+ scheduled: 'Scheduled',
48
+ failed: 'Failed',
49
+ succeeded: 'Succeeded',
50
+ };
7
51
 
8
- const FilteredList = ({ rows }: { rows: Row[] }) => {
52
+ const ALL_FILTERS: FilterKey[] = ['scheduled', 'failed', 'succeeded'];
53
+
54
+ const SearchField = ({
55
+ value,
56
+ onValueChange,
57
+ }: {
58
+ value: string;
59
+ onValueChange: (next: string) => void;
60
+ }) => (
61
+ <InputGroup className="tw-w-[300px]">
62
+ <InputGroup.Addon align="inline-start">
63
+ <Search className="tw-size-4 tw-text-muted-foreground" />
64
+ </InputGroup.Addon>
65
+ <InputGroup.Input
66
+ placeholder="Search"
67
+ value={value}
68
+ onChange={(event) => onValueChange(event.target.value)}
69
+ />
70
+ <InputGroup.Addon align="inline-end">
71
+ <Tooltip tooltip="Searches by name and description." triggerAsChild>
72
+ <InputGroup.Button tabIndex={0} aria-label="Search help">
73
+ <Info className="tw-size-4 tw-text-muted-foreground" />
74
+ </InputGroup.Button>
75
+ </Tooltip>
76
+ </InputGroup.Addon>
77
+ </InputGroup>
78
+ );
79
+
80
+ const ActiveFiltersRow = ({
81
+ active,
82
+ onRemove,
83
+ onAdd,
84
+ onClear,
85
+ }: {
86
+ active: FilterKey[];
87
+ onRemove: (key: FilterKey) => void;
88
+ onAdd: () => void;
89
+ onClear: () => void;
90
+ }) => (
91
+ <div className="tw-flex tw-w-full tw-items-center tw-gap-2 tw-rounded-xl tw-bg-muted tw-py-1 tw-pl-2 tw-pr-1">
92
+ <div className="tw-flex tw-min-w-0 tw-flex-1 tw-items-center tw-gap-2">
93
+ {active.map((key) => (
94
+ <Badge
95
+ key={key}
96
+ variant="pill"
97
+ color="blue-inverse"
98
+ suffix={
99
+ <Button
100
+ variant="inline"
101
+ size="icon-2xs"
102
+ aria-label={`Remove ${FILTER_LABEL[key]} filter`}
103
+ onClick={() => onRemove(key)}
104
+ >
105
+ <X className="tw-size-3" />
106
+ </Button>
107
+ }
108
+ >
109
+ {FILTER_LABEL[key]}
110
+ </Badge>
111
+ ))}
112
+ <Button argo variant="invisible" size="icon-sm" aria-label="Add filter" onClick={onAdd}>
113
+ <Plus className="tw-size-4" />
114
+ </Button>
115
+ </div>
116
+ <Button argo variant="invisible" size="small" onClick={onClear}>
117
+ Clear
118
+ </Button>
119
+ </div>
120
+ );
121
+
122
+ const FilteredList = ({
123
+ rows,
124
+ showSort,
125
+ showViewToggle,
126
+ showRefresh,
127
+ showReorder,
128
+ }: {
129
+ rows: Row[];
130
+ showSort: boolean;
131
+ showViewToggle: boolean;
132
+ showRefresh: boolean;
133
+ showReorder: boolean;
134
+ }) => {
9
135
  const [query, setQuery] = useState('');
10
- const [type, setType] = useState<Row['type'] | null>(null);
11
-
12
- const filtered = useMemo(
13
- () =>
14
- rows.filter((row) => {
15
- const matchesQuery = row.name.toLowerCase().includes(query.toLowerCase());
16
- const matchesType = type ? row.type === type : true;
17
- return matchesQuery && matchesType;
18
- }),
19
- [rows, query, type],
20
- );
136
+ const [sort, setSort] = useState<SortKey>('name-desc');
137
+ const [view, setView] = useState<ViewMode>('list');
138
+ const [active, setActive] = useState<FilterKey[]>(['scheduled', 'failed']);
139
+
140
+ const filtered = useMemo(() => {
141
+ const byQuery = rows.filter((row) => row.name.toLowerCase().includes(query.toLowerCase()));
142
+ const byStatus = active.length ? byQuery.filter((row) => active.includes(row.status)) : byQuery;
143
+ return [...byStatus].sort((a, b) => {
144
+ if (sort === 'name-asc') return a.name.localeCompare(b.name);
145
+ if (sort === 'name-desc') return b.name.localeCompare(a.name);
146
+ return b.id.localeCompare(a.id);
147
+ });
148
+ }, [rows, query, active, sort]);
149
+
150
+ const addFilter = () => {
151
+ setActive((prev) => {
152
+ const next = ALL_FILTERS.find((key) => !prev.includes(key));
153
+ return next ? [...prev, next] : prev;
154
+ });
155
+ };
156
+
157
+ const showRightCluster = showSort || showViewToggle || showRefresh || showReorder;
158
+ const showUtilities = showRefresh || showReorder;
159
+ const showDivider = showSort && (showUtilities || showViewToggle);
21
160
 
22
161
  return (
23
162
  <PageLayout>
24
- <PageLayout.Toolbar>
25
- <SearchInput
26
- allowClear
27
- placeholder="Search by name"
28
- value={query}
29
- onValueChange={setQuery}
30
- className="tw-w-64"
31
- />
32
- <Combobox
33
- allowClear
34
- items={['table', 'file'] as const}
35
- value={type}
36
- onValueChange={setType}
37
- placeholder="Type"
38
- className="tw-w-40"
39
- />
40
- <span className="tw-text-sm tw-text-muted-foreground">{filtered.length} results</span>
41
- </PageLayout.Toolbar>
163
+ <PageToolbar className="tw-flex-col tw-flex-nowrap tw-items-stretch tw-gap-2">
164
+ <div className="tw-flex tw-w-full tw-items-center tw-gap-6">
165
+ <div className="tw-flex tw-min-w-0 tw-flex-1">
166
+ <SearchField value={query} onValueChange={setQuery} />
167
+ </div>
168
+ {showRightCluster && (
169
+ <div className="tw-flex tw-items-center tw-gap-2">
170
+ <Button argo variant="outline" size="icon" aria-label="Open filters">
171
+ <ListFilter className="tw-size-4" />
172
+ </Button>
173
+ {showSort && (
174
+ <Select
175
+ variant="argo"
176
+ className="tw-w-[200px]"
177
+ value={sort}
178
+ onValueChange={(next: SortKey) => setSort(next)}
179
+ items={(Object.keys(SORT_LABEL) as SortKey[]).map((key) => ({
180
+ value: key,
181
+ label: SORT_LABEL[key],
182
+ }))}
183
+ />
184
+ )}
185
+ {showDivider && <Separator orientation="vertical" className="tw-h-9" />}
186
+ {showReorder && (
187
+ <Button argo variant="outline" size="icon" aria-label="Reorder">
188
+ <ChevronsUpDown className="tw-size-4" />
189
+ </Button>
190
+ )}
191
+ {showRefresh && (
192
+ <Button argo variant="outline" size="icon" aria-label="Refresh">
193
+ <RefreshCw className="tw-size-4" />
194
+ </Button>
195
+ )}
196
+ {showViewToggle && (
197
+ <ToggleGroup
198
+ type="single"
199
+ value={view}
200
+ onValueChange={(next: ViewMode | '') => next && setView(next)}
201
+ aria-label="View mode"
202
+ >
203
+ <ToggleGroup.Item value="list" aria-label="List view">
204
+ <List className="tw-size-4" />
205
+ </ToggleGroup.Item>
206
+ <ToggleGroup.Item value="grid" aria-label="Grid view">
207
+ <LayoutGrid className="tw-size-4" />
208
+ </ToggleGroup.Item>
209
+ </ToggleGroup>
210
+ )}
211
+ </div>
212
+ )}
213
+ </div>
214
+ {active.length > 0 && (
215
+ <ActiveFiltersRow
216
+ active={active}
217
+ onRemove={(key) => setActive((prev) => prev.filter((k) => k !== key))}
218
+ onAdd={addFilter}
219
+ onClear={() => setActive([])}
220
+ />
221
+ )}
222
+ </PageToolbar>
42
223
  <PageLayout.Content>
43
- <ul className="tw-flex tw-flex-col tw-gap-1">
224
+ <ul className="tw-flex tw-flex-col tw-gap-1 tw-text-sm">
44
225
  {filtered.map((row) => (
45
- <li key={row.id}>{row.name}</li>
226
+ <li key={row.id} className="tw-flex tw-items-center tw-gap-2">
227
+ <span className="tw-text-muted-foreground">[{row.status}]</span>
228
+ {row.name}
229
+ </li>
46
230
  ))}
47
231
  </ul>
48
232
  </PageLayout.Content>
@@ -53,19 +237,39 @@ const FilteredList = ({ rows }: { rows: Row[] }) => {
53
237
  const meta = {
54
238
  title: 'Patterns/FilterToolbar',
55
239
  component: FilteredList,
240
+ parameters: { layout: 'padded' },
241
+ args: {
242
+ showSort: true,
243
+ showViewToggle: true,
244
+ showRefresh: false,
245
+ showReorder: false,
246
+ rows: [
247
+ { id: '1', name: 'marketing.users', type: 'table', status: 'succeeded' },
248
+ { id: '2', name: 'sales.leads', type: 'table', status: 'scheduled' },
249
+ { id: '3', name: 'export.csv', type: 'file', status: 'failed' },
250
+ { id: '4', name: 'snapshot.json', type: 'file', status: 'scheduled' },
251
+ { id: '5', name: 'events.parquet', type: 'file', status: 'failed' },
252
+ ],
253
+ },
56
254
  } satisfies Meta<typeof FilteredList>;
57
255
 
58
256
  export default meta;
59
-
60
257
  type Story = StoryObj<typeof meta>;
61
258
 
62
- export const Default: Story = {
63
- args: {
64
- rows: [
65
- { id: '1', name: 'marketing.users', type: 'table' },
66
- { id: '2', name: 'sales.leads', type: 'table' },
67
- { id: '3', name: 'export.csv', type: 'file' },
68
- { id: '4', name: 'snapshot.json', type: 'file' },
69
- ],
70
- },
259
+ /** Full toolbar — search, filter, sort, view-mode toggle, and the active-filters row. */
260
+ export const Default: Story = {};
261
+
262
+ /** Minimal variant: only the search field and a filter button. */
263
+ export const SearchAndFilterOnly: Story = {
264
+ args: { showSort: false, showViewToggle: false },
265
+ };
266
+
267
+ /** Adds the refresh utility next to the sort, separated by a vertical divider. */
268
+ export const WithRefresh: Story = {
269
+ args: { showRefresh: true },
270
+ };
271
+
272
+ /** Reorder + refresh utilities. View toggle hidden, mirrors a sortable list page. */
273
+ export const WithReorderUtilities: Story = {
274
+ args: { showViewToggle: false, showRefresh: true, showReorder: true },
71
275
  };