@mindlogic-ai/logician-ui 4.0.0-alpha.13 → 4.0.0-alpha.14
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/components/Button/Button.styles.d.ts.map +1 -1
- package/dist/components/Button/Button.styles.js +17 -12
- package/dist/components/Button/Button.styles.js.map +1 -1
- package/dist/components/Button/Button.styles.mjs +17 -12
- package/dist/components/Button/Button.styles.mjs.map +1 -1
- package/dist/components/Card/Card.styles.d.ts +4 -0
- package/dist/components/Card/Card.styles.d.ts.map +1 -1
- package/dist/components/Card/Card.styles.js +8 -0
- package/dist/components/Card/Card.styles.js.map +1 -1
- package/dist/components/Card/Card.styles.mjs +8 -0
- package/dist/components/Card/Card.styles.mjs.map +1 -1
- package/dist/components/Card/Card.types.d.ts +1 -1
- package/dist/components/Card/Card.types.d.ts.map +1 -1
- package/dist/components/Form/FieldError/FieldError.d.ts +15 -0
- package/dist/components/Form/FieldError/FieldError.d.ts.map +1 -0
- package/dist/components/Form/FieldError/FieldError.js +22 -0
- package/dist/components/Form/FieldError/FieldError.js.map +1 -0
- package/dist/components/Form/FieldError/FieldError.mjs +20 -0
- package/dist/components/Form/FieldError/FieldError.mjs.map +1 -0
- package/dist/components/Form/FieldError/index.d.ts +3 -0
- package/dist/components/Form/FieldError/index.d.ts.map +1 -0
- package/dist/components/Form/FieldHelp/FieldHelp.d.ts +14 -0
- package/dist/components/Form/FieldHelp/FieldHelp.d.ts.map +1 -0
- package/dist/components/Form/FieldHelp/FieldHelp.js +19 -0
- package/dist/components/Form/FieldHelp/FieldHelp.js.map +1 -0
- package/dist/components/Form/FieldHelp/FieldHelp.mjs +17 -0
- package/dist/components/Form/FieldHelp/FieldHelp.mjs.map +1 -0
- package/dist/components/Form/FieldHelp/index.d.ts +3 -0
- package/dist/components/Form/FieldHelp/index.d.ts.map +1 -0
- package/dist/components/Form/FieldLabel/FieldLabel.d.ts +12 -0
- package/dist/components/Form/FieldLabel/FieldLabel.d.ts.map +1 -0
- package/dist/components/Form/FieldLabel/FieldLabel.js +32 -0
- package/dist/components/Form/FieldLabel/FieldLabel.js.map +1 -0
- package/dist/components/Form/FieldLabel/FieldLabel.mjs +30 -0
- package/dist/components/Form/FieldLabel/FieldLabel.mjs.map +1 -0
- package/dist/components/Form/FieldLabel/FieldLabel.types.d.ts +21 -0
- package/dist/components/Form/FieldLabel/FieldLabel.types.d.ts.map +1 -0
- package/dist/components/Form/FieldLabel/index.d.ts +3 -0
- package/dist/components/Form/FieldLabel/index.d.ts.map +1 -0
- package/dist/components/Form/FieldRow/FieldRow.d.ts +15 -0
- package/dist/components/Form/FieldRow/FieldRow.d.ts.map +1 -0
- package/dist/components/Form/FieldRow/FieldRow.js +27 -0
- package/dist/components/Form/FieldRow/FieldRow.js.map +1 -0
- package/dist/components/Form/FieldRow/FieldRow.mjs +25 -0
- package/dist/components/Form/FieldRow/FieldRow.mjs.map +1 -0
- package/dist/components/Form/FieldRow/FieldRow.types.d.ts +27 -0
- package/dist/components/Form/FieldRow/FieldRow.types.d.ts.map +1 -0
- package/dist/components/Form/FieldRow/index.d.ts +3 -0
- package/dist/components/Form/FieldRow/index.d.ts.map +1 -0
- package/dist/components/Form/FormFooter/FormFooter.d.ts +11 -0
- package/dist/components/Form/FormFooter/FormFooter.d.ts.map +1 -0
- package/dist/components/Form/FormFooter/FormFooter.js +20 -0
- package/dist/components/Form/FormFooter/FormFooter.js.map +1 -0
- package/dist/components/Form/FormFooter/FormFooter.mjs +18 -0
- package/dist/components/Form/FormFooter/FormFooter.mjs.map +1 -0
- package/dist/components/Form/FormFooter/FormFooter.types.d.ts +23 -0
- package/dist/components/Form/FormFooter/FormFooter.types.d.ts.map +1 -0
- package/dist/components/Form/FormFooter/index.d.ts +3 -0
- package/dist/components/Form/FormFooter/index.d.ts.map +1 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.d.ts +19 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.d.ts.map +1 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.js +42 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.js.map +1 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.mjs +40 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.mjs.map +1 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.types.d.ts +49 -0
- package/dist/components/Form/FormModalFooter/FormModalFooter.types.d.ts.map +1 -0
- package/dist/components/Form/FormModalFooter/index.d.ts +3 -0
- package/dist/components/Form/FormModalFooter/index.d.ts.map +1 -0
- package/dist/components/Form/FormScrollArea/FormScrollArea.d.ts +14 -0
- package/dist/components/Form/FormScrollArea/FormScrollArea.d.ts.map +1 -0
- package/dist/components/Form/FormScrollArea/FormScrollArea.js +18 -0
- package/dist/components/Form/FormScrollArea/FormScrollArea.js.map +1 -0
- package/dist/components/Form/FormScrollArea/FormScrollArea.mjs +16 -0
- package/dist/components/Form/FormScrollArea/FormScrollArea.mjs.map +1 -0
- package/dist/components/Form/FormScrollArea/index.d.ts +2 -0
- package/dist/components/Form/FormScrollArea/index.d.ts.map +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.d.ts +10 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.d.ts.map +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.js +43 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.js.map +1 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.mjs +41 -0
- package/dist/components/Form/FormSection/CollapsibleFormSection.mjs.map +1 -0
- package/dist/components/Form/FormSection/FlatFormSection.d.ts +6 -0
- package/dist/components/Form/FormSection/FlatFormSection.d.ts.map +1 -0
- package/dist/components/Form/FormSection/FlatFormSection.js +12 -0
- package/dist/components/Form/FormSection/FlatFormSection.js.map +1 -0
- package/dist/components/Form/FormSection/FlatFormSection.mjs +10 -0
- package/dist/components/Form/FormSection/FlatFormSection.mjs.map +1 -0
- package/dist/components/Form/FormSection/FormSection.d.ts +13 -0
- package/dist/components/Form/FormSection/FormSection.d.ts.map +1 -0
- package/dist/components/Form/FormSection/FormSection.js +21 -0
- package/dist/components/Form/FormSection/FormSection.js.map +1 -0
- package/dist/components/Form/FormSection/FormSection.mjs +19 -0
- package/dist/components/Form/FormSection/FormSection.mjs.map +1 -0
- package/dist/components/Form/FormSection/FormSection.types.d.ts +34 -0
- package/dist/components/Form/FormSection/FormSection.types.d.ts.map +1 -0
- package/dist/components/Form/FormSection/index.d.ts +3 -0
- package/dist/components/Form/FormSection/index.d.ts.map +1 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.d.ts +13 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.d.ts.map +1 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.js +30 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.js.map +1 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.mjs +28 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.mjs.map +1 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.types.d.ts +17 -0
- package/dist/components/Form/FormSectionHeader/FormSectionHeader.types.d.ts.map +1 -0
- package/dist/components/Form/FormSectionHeader/index.d.ts +3 -0
- package/dist/components/Form/FormSectionHeader/index.d.ts.map +1 -0
- package/dist/components/Form/index.d.ts +18 -0
- package/dist/components/Form/index.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/Button/Button.styles.ts +17 -12
- package/src/components/Card/Card.stories.tsx +7 -0
- package/src/components/Card/Card.styles.ts +8 -0
- package/src/components/Card/Card.types.ts +1 -1
- package/src/components/Form/FieldError/FieldError.test.tsx +33 -0
- package/src/components/Form/FieldError/FieldError.tsx +20 -0
- package/src/components/Form/FieldError/index.ts +2 -0
- package/src/components/Form/FieldHelp/FieldHelp.tsx +18 -0
- package/src/components/Form/FieldHelp/index.ts +2 -0
- package/src/components/Form/FieldLabel/FieldLabel.tsx +46 -0
- package/src/components/Form/FieldLabel/FieldLabel.types.ts +21 -0
- package/src/components/Form/FieldLabel/index.ts +2 -0
- package/src/components/Form/FieldRow/FieldRow.stories.tsx +85 -0
- package/src/components/Form/FieldRow/FieldRow.test.tsx +89 -0
- package/src/components/Form/FieldRow/FieldRow.tsx +46 -0
- package/src/components/Form/FieldRow/FieldRow.types.ts +32 -0
- package/src/components/Form/FieldRow/index.ts +2 -0
- package/src/components/Form/FormFooter/FormFooter.stories.tsx +70 -0
- package/src/components/Form/FormFooter/FormFooter.test.tsx +67 -0
- package/src/components/Form/FormFooter/FormFooter.tsx +46 -0
- package/src/components/Form/FormFooter/FormFooter.types.ts +23 -0
- package/src/components/Form/FormFooter/index.ts +2 -0
- package/src/components/Form/FormModalFooter/FormModalFooter.stories.tsx +64 -0
- package/src/components/Form/FormModalFooter/FormModalFooter.test.tsx +155 -0
- package/src/components/Form/FormModalFooter/FormModalFooter.tsx +83 -0
- package/src/components/Form/FormModalFooter/FormModalFooter.types.ts +53 -0
- package/src/components/Form/FormModalFooter/index.ts +2 -0
- package/src/components/Form/FormScrollArea/FormScrollArea.tsx +27 -0
- package/src/components/Form/FormScrollArea/index.ts +1 -0
- package/src/components/Form/FormSection/CollapsibleFormSection.tsx +35 -0
- package/src/components/Form/FormSection/FlatFormSection.tsx +41 -0
- package/src/components/Form/FormSection/FormSection.stories.tsx +115 -0
- package/src/components/Form/FormSection/FormSection.test.tsx +89 -0
- package/src/components/Form/FormSection/FormSection.tsx +33 -0
- package/src/components/Form/FormSection/FormSection.types.ts +34 -0
- package/src/components/Form/FormSection/index.ts +2 -0
- package/src/components/Form/FormSectionHeader/FormSectionHeader.stories.tsx +53 -0
- package/src/components/Form/FormSectionHeader/FormSectionHeader.test.tsx +74 -0
- package/src/components/Form/FormSectionHeader/FormSectionHeader.tsx +48 -0
- package/src/components/Form/FormSectionHeader/FormSectionHeader.types.ts +17 -0
- package/src/components/Form/FormSectionHeader/index.ts +2 -0
- package/src/components/Form/index.ts +17 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { FormModalFooter } from './FormModalFooter';
|
|
6
|
+
|
|
7
|
+
// Surface the Button's type/disabled/loading onto the DOM so behavior is
|
|
8
|
+
// testable without a Chakra provider. The copy-agnostic footer owns no
|
|
9
|
+
// translations, so labels are passed explicitly by the caller.
|
|
10
|
+
vi.mock('@/components/Modal', () => ({
|
|
11
|
+
ModalFooter: ({ children }: { children?: ReactNode }) => (
|
|
12
|
+
<div data-testid="footer">{children}</div>
|
|
13
|
+
),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('@/components/Button', () => ({
|
|
17
|
+
Button: ({
|
|
18
|
+
children,
|
|
19
|
+
onClick,
|
|
20
|
+
type,
|
|
21
|
+
disabled,
|
|
22
|
+
loading,
|
|
23
|
+
}: {
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
onClick?: () => void;
|
|
26
|
+
type?: 'button' | 'submit';
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
}) => (
|
|
30
|
+
<button
|
|
31
|
+
type={type}
|
|
32
|
+
onClick={onClick}
|
|
33
|
+
disabled={disabled}
|
|
34
|
+
data-loading={loading ? 'true' : 'false'}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
</button>
|
|
38
|
+
),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('@/components/Icon', () => ({
|
|
42
|
+
TrashIcon: () => <span data-testid="trash" />,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const noop = () => {};
|
|
46
|
+
|
|
47
|
+
describe('FormModalFooter', () => {
|
|
48
|
+
it('renders a Cancel + submit pair with the given labels', () => {
|
|
49
|
+
render(
|
|
50
|
+
<FormModalFooter
|
|
51
|
+
onCancel={noop}
|
|
52
|
+
onSubmit={noop}
|
|
53
|
+
cancelLabel="Cancel"
|
|
54
|
+
submitLabel="Save"
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
|
59
|
+
// No delete button unless onDelete is provided.
|
|
60
|
+
expect(screen.queryByTestId('trash')).not.toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('routes cancel and submit clicks to their handlers', () => {
|
|
64
|
+
const onCancel = vi.fn();
|
|
65
|
+
const onSubmit = vi.fn();
|
|
66
|
+
render(
|
|
67
|
+
<FormModalFooter
|
|
68
|
+
onCancel={onCancel}
|
|
69
|
+
onSubmit={onSubmit}
|
|
70
|
+
cancelLabel="Cancel"
|
|
71
|
+
submitLabel="Save"
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
|
75
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
76
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is a type="submit" button inside a form (no onSubmit), type="button" with onSubmit', () => {
|
|
81
|
+
const { rerender } = render(
|
|
82
|
+
<FormModalFooter
|
|
83
|
+
onCancel={noop}
|
|
84
|
+
cancelLabel="Cancel"
|
|
85
|
+
submitLabel="Save"
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute(
|
|
89
|
+
'type',
|
|
90
|
+
'submit'
|
|
91
|
+
);
|
|
92
|
+
rerender(
|
|
93
|
+
<FormModalFooter
|
|
94
|
+
onCancel={noop}
|
|
95
|
+
onSubmit={noop}
|
|
96
|
+
cancelLabel="Cancel"
|
|
97
|
+
submitLabel="Save"
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute(
|
|
101
|
+
'type',
|
|
102
|
+
'button'
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('renders the standard delete button and routes its click', () => {
|
|
107
|
+
const onDelete = vi.fn();
|
|
108
|
+
render(
|
|
109
|
+
<FormModalFooter
|
|
110
|
+
onCancel={noop}
|
|
111
|
+
onSubmit={noop}
|
|
112
|
+
cancelLabel="Cancel"
|
|
113
|
+
submitLabel="Save"
|
|
114
|
+
onDelete={onDelete}
|
|
115
|
+
deleteLabel="Delete"
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
expect(screen.getByTestId('trash')).toBeInTheDocument();
|
|
119
|
+
fireEvent.click(screen.getByRole('button', { name: /Delete/ }));
|
|
120
|
+
expect(onDelete).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('disables every button and shows the submit spinner while submitting', () => {
|
|
124
|
+
render(
|
|
125
|
+
<FormModalFooter
|
|
126
|
+
onCancel={noop}
|
|
127
|
+
onSubmit={noop}
|
|
128
|
+
cancelLabel="Cancel"
|
|
129
|
+
submitLabel="Save"
|
|
130
|
+
onDelete={noop}
|
|
131
|
+
deleteLabel="Delete"
|
|
132
|
+
isSubmitting
|
|
133
|
+
/>
|
|
134
|
+
);
|
|
135
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
|
136
|
+
expect(screen.getByRole('button', { name: /Delete/ })).toBeDisabled();
|
|
137
|
+
const submit = screen.getByRole('button', { name: 'Save' });
|
|
138
|
+
expect(submit).toBeDisabled();
|
|
139
|
+
expect(submit).toHaveAttribute('data-loading', 'true');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('disables only the submit button when submitDisabled is set', () => {
|
|
143
|
+
render(
|
|
144
|
+
<FormModalFooter
|
|
145
|
+
onCancel={noop}
|
|
146
|
+
onSubmit={noop}
|
|
147
|
+
cancelLabel="Cancel"
|
|
148
|
+
submitLabel="Save"
|
|
149
|
+
submitDisabled
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
|
153
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).not.toBeDisabled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Button } from '@/components/Button';
|
|
2
|
+
import { TrashIcon } from '@/components/Icon';
|
|
3
|
+
import { ModalFooter } from '@/components/Modal';
|
|
4
|
+
|
|
5
|
+
import { FormModalFooterProps } from './FormModalFooter.types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The action footer shared by form-in-a-modal surfaces — the form analog of a
|
|
9
|
+
* confirm-modal footer. It standardizes the button trio every create/edit modal
|
|
10
|
+
* hand-rolls: an optional far-left destructive action, a neutral Cancel, and the
|
|
11
|
+
* primary submit that carries its own loading + disabled state.
|
|
12
|
+
*
|
|
13
|
+
* Copy-agnostic by design: as a design-system component it owns no strings, so
|
|
14
|
+
* `cancelLabel` is required and `deleteLabel` is rendered verbatim — the
|
|
15
|
+
* consuming app supplies its own (translated) labels.
|
|
16
|
+
*
|
|
17
|
+
* Submit path: inside a `<form onSubmit>` leave `onSubmit` unset so the button
|
|
18
|
+
* stays `type="submit"` (Enter submits); for a portaled or no-`<form>` modal
|
|
19
|
+
* pass `onSubmit` and it becomes a `type="button"` onClick. `...rest` forwards to
|
|
20
|
+
* the logician `ModalFooter`, so callers pin it (`position="sticky"` /
|
|
21
|
+
* `flexShrink={0}`) or zero its padding (`px={0}`) per modal.
|
|
22
|
+
*/
|
|
23
|
+
export const FormModalFooter = ({
|
|
24
|
+
onDelete,
|
|
25
|
+
deleteLabel,
|
|
26
|
+
onCancel,
|
|
27
|
+
cancelLabel,
|
|
28
|
+
submitLabel,
|
|
29
|
+
submitColorPalette = 'primary',
|
|
30
|
+
isSubmitting = false,
|
|
31
|
+
submitDisabled = false,
|
|
32
|
+
onSubmit,
|
|
33
|
+
bordered = false,
|
|
34
|
+
...rest
|
|
35
|
+
}: FormModalFooterProps) => {
|
|
36
|
+
return (
|
|
37
|
+
<ModalFooter
|
|
38
|
+
justifyContent="flex-end"
|
|
39
|
+
gap={2}
|
|
40
|
+
{...(bordered
|
|
41
|
+
? {
|
|
42
|
+
bg: 'bg.surface',
|
|
43
|
+
borderTop: '1px solid',
|
|
44
|
+
borderColor: 'border.subtle',
|
|
45
|
+
}
|
|
46
|
+
: {})}
|
|
47
|
+
{...rest}
|
|
48
|
+
>
|
|
49
|
+
{onDelete && (
|
|
50
|
+
<Button
|
|
51
|
+
colorPalette="danger"
|
|
52
|
+
variant="solid"
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={onDelete}
|
|
55
|
+
disabled={isSubmitting}
|
|
56
|
+
mr="auto"
|
|
57
|
+
>
|
|
58
|
+
<TrashIcon boxSize="sm" />
|
|
59
|
+
{deleteLabel}
|
|
60
|
+
</Button>
|
|
61
|
+
)}
|
|
62
|
+
<Button
|
|
63
|
+
colorPalette="neutral"
|
|
64
|
+
variant="outline"
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={onCancel}
|
|
67
|
+
disabled={isSubmitting}
|
|
68
|
+
>
|
|
69
|
+
{cancelLabel}
|
|
70
|
+
</Button>
|
|
71
|
+
<Button
|
|
72
|
+
colorPalette={submitColorPalette}
|
|
73
|
+
variant="solid"
|
|
74
|
+
type={onSubmit ? 'button' : 'submit'}
|
|
75
|
+
onClick={onSubmit}
|
|
76
|
+
loading={isSubmitting}
|
|
77
|
+
disabled={isSubmitting || submitDisabled}
|
|
78
|
+
>
|
|
79
|
+
{submitLabel}
|
|
80
|
+
</Button>
|
|
81
|
+
</ModalFooter>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ComponentProps, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { ButtonColorPalette } from '@/components/Button';
|
|
4
|
+
import { ModalFooter } from '@/components/Modal';
|
|
5
|
+
|
|
6
|
+
type ModalFooterProps = ComponentProps<typeof ModalFooter>;
|
|
7
|
+
|
|
8
|
+
export interface FormModalFooterProps extends Omit<
|
|
9
|
+
ModalFooterProps,
|
|
10
|
+
'children' | 'onSubmit' | 'title'
|
|
11
|
+
> {
|
|
12
|
+
/**
|
|
13
|
+
* Optional left-aligned destructive action. Renders the standard danger
|
|
14
|
+
* delete button (trash glyph + label), pushed to the far left of the footer.
|
|
15
|
+
*/
|
|
16
|
+
onDelete?: () => void;
|
|
17
|
+
/** Delete button label. Rendered verbatim (no default — the app owns copy). */
|
|
18
|
+
deleteLabel?: ReactNode;
|
|
19
|
+
/** Cancel/dismiss handler. */
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
/**
|
|
22
|
+
* Cancel button label. Required — a design-system component owns no copy, so
|
|
23
|
+
* the consuming app must supply the (translated) label.
|
|
24
|
+
*/
|
|
25
|
+
cancelLabel: ReactNode;
|
|
26
|
+
/** Primary submit button label. */
|
|
27
|
+
submitLabel: ReactNode;
|
|
28
|
+
/** Palette for the primary button. Defaults to `primary`. */
|
|
29
|
+
submitColorPalette?: ButtonColorPalette;
|
|
30
|
+
/**
|
|
31
|
+
* In-flight state — shows the submit spinner and disables every button so the
|
|
32
|
+
* modal can't be dismissed or re-submitted mid-request.
|
|
33
|
+
*/
|
|
34
|
+
isSubmitting?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Extra disable condition for the primary button beyond `isSubmitting` — a
|
|
37
|
+
* pristine/invalid gate (e.g. `!name.trim()`).
|
|
38
|
+
*/
|
|
39
|
+
submitDisabled?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Submit via `onClick` instead of native form submission. Pass this for a
|
|
42
|
+
* portaled or no-`<form>` modal, where a `type="submit"` button can't reach
|
|
43
|
+
* the enclosing `<form>`. Omit inside a `<form onSubmit>` so the button stays
|
|
44
|
+
* `type="submit"` and Enter still submits.
|
|
45
|
+
*/
|
|
46
|
+
onSubmit?: () => void;
|
|
47
|
+
/**
|
|
48
|
+
* Draw the scroll-separator chrome (surface background + top hairline). Set on
|
|
49
|
+
* a tall scroll-inside modal so the pinned footer reads as separate from the
|
|
50
|
+
* scrolling body; omit on a short modal.
|
|
51
|
+
*/
|
|
52
|
+
bordered?: boolean;
|
|
53
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PropsWithChildren } from 'react';
|
|
2
|
+
import { Box, BoxProps } from '@chakra-ui/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The scrolling body of a form panel: a full-height flex column that scrolls
|
|
6
|
+
* vertically, so a `position="sticky" bottom={0}` `FormFooter` pins to its
|
|
7
|
+
* bottom edge. Extracted from `FloatingPanelCard` so the same "scroll column +
|
|
8
|
+
* pinned footer" geometry can back any left-panel form without pulling in the
|
|
9
|
+
* card chrome.
|
|
10
|
+
*/
|
|
11
|
+
export const FormScrollArea = ({
|
|
12
|
+
children,
|
|
13
|
+
...rest
|
|
14
|
+
}: PropsWithChildren<BoxProps>) => (
|
|
15
|
+
<Box
|
|
16
|
+
h="100%"
|
|
17
|
+
overflowY="auto"
|
|
18
|
+
position="relative"
|
|
19
|
+
display="flex"
|
|
20
|
+
flexDirection="column"
|
|
21
|
+
{...rest}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</Box>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
FormScrollArea.displayName = 'FormScrollArea';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FormScrollArea } from './FormScrollArea';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { VStack } from '@chakra-ui/react';
|
|
2
|
+
|
|
3
|
+
import { CollapsibleSection } from '@/components/Workflow';
|
|
4
|
+
|
|
5
|
+
import { FormSectionProps } from './FormSection.types';
|
|
6
|
+
|
|
7
|
+
type CollapsibleFormSectionProps = Pick<
|
|
8
|
+
FormSectionProps,
|
|
9
|
+
'title' | 'action' | 'defaultOpen' | 'hasError' | 'gap' | 'children'
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The collapsible `FormSection` variant — delegates to the logician
|
|
14
|
+
* `CollapsibleSection` accordion (which owns its own layout, so container style
|
|
15
|
+
* overrides apply to the flat variant only). Requires a title.
|
|
16
|
+
*/
|
|
17
|
+
export const CollapsibleFormSection = ({
|
|
18
|
+
title,
|
|
19
|
+
action,
|
|
20
|
+
defaultOpen = true,
|
|
21
|
+
hasError = false,
|
|
22
|
+
gap = 4,
|
|
23
|
+
children,
|
|
24
|
+
}: CollapsibleFormSectionProps) => (
|
|
25
|
+
<CollapsibleSection
|
|
26
|
+
label={title ?? ''}
|
|
27
|
+
defaultExpanded={defaultOpen}
|
|
28
|
+
hasError={hasError}
|
|
29
|
+
headerAction={action}
|
|
30
|
+
>
|
|
31
|
+
<VStack align="stretch" gap={gap} w="100%">
|
|
32
|
+
{children}
|
|
33
|
+
</VStack>
|
|
34
|
+
</CollapsibleSection>
|
|
35
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Box, VStack } from '@chakra-ui/react';
|
|
2
|
+
|
|
3
|
+
import { FormSectionHeader } from '../FormSectionHeader';
|
|
4
|
+
import { FormSectionProps } from './FormSection.types';
|
|
5
|
+
|
|
6
|
+
type FlatFormSectionProps = Omit<
|
|
7
|
+
FormSectionProps,
|
|
8
|
+
'collapsible' | 'defaultOpen' | 'hasError'
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
/** The flat (non-collapsible) `FormSection` variant. */
|
|
12
|
+
export const FlatFormSection = ({
|
|
13
|
+
title,
|
|
14
|
+
titleAdornment,
|
|
15
|
+
description,
|
|
16
|
+
action,
|
|
17
|
+
divider = false,
|
|
18
|
+
gap = 4,
|
|
19
|
+
children,
|
|
20
|
+
...boxProps
|
|
21
|
+
}: FlatFormSectionProps) => (
|
|
22
|
+
<Box
|
|
23
|
+
w="100%"
|
|
24
|
+
pt={divider ? 4 : undefined}
|
|
25
|
+
borderTopWidth={divider ? '1px' : undefined}
|
|
26
|
+
borderColor={divider ? 'border.subtle' : undefined}
|
|
27
|
+
{...boxProps}
|
|
28
|
+
>
|
|
29
|
+
{(title || titleAdornment || description || action) && (
|
|
30
|
+
<FormSectionHeader
|
|
31
|
+
title={title}
|
|
32
|
+
titleAdornment={titleAdornment}
|
|
33
|
+
description={description}
|
|
34
|
+
action={action}
|
|
35
|
+
/>
|
|
36
|
+
)}
|
|
37
|
+
<VStack align="stretch" gap={gap} w="100%">
|
|
38
|
+
{children}
|
|
39
|
+
</VStack>
|
|
40
|
+
</Box>
|
|
41
|
+
);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { VStack } from '@chakra-ui/react';
|
|
2
|
+
import { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/Button';
|
|
5
|
+
import { InfoSprinkle } from '@/components/InfoSprinkle';
|
|
6
|
+
import { Input } from '@/components/Input';
|
|
7
|
+
|
|
8
|
+
import { FieldRow } from '../FieldRow';
|
|
9
|
+
import { FormSection } from './FormSection';
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof FormSection> = {
|
|
12
|
+
title: 'Components/Form/FormSection',
|
|
13
|
+
component: FormSection,
|
|
14
|
+
args: {
|
|
15
|
+
title: 'Profile',
|
|
16
|
+
children: (
|
|
17
|
+
<>
|
|
18
|
+
<FieldRow label="Display name">
|
|
19
|
+
<Input placeholder="Enter a name" />
|
|
20
|
+
</FieldRow>
|
|
21
|
+
<FieldRow label="Tagline" description="A short one-line summary.">
|
|
22
|
+
<Input placeholder="Enter a tagline" />
|
|
23
|
+
</FieldRow>
|
|
24
|
+
</>
|
|
25
|
+
),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof FormSection>;
|
|
31
|
+
|
|
32
|
+
export const Flat: Story = {};
|
|
33
|
+
|
|
34
|
+
export const WithDescription: Story = {
|
|
35
|
+
args: { description: 'How this assistant introduces itself.' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithAction: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
action: (
|
|
41
|
+
<Button size="sm" variant="outline">
|
|
42
|
+
Add
|
|
43
|
+
</Button>
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const WithDivider: Story = {
|
|
49
|
+
args: { divider: true },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Collapsible: Story = {
|
|
53
|
+
args: { collapsible: true },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const CollapsibleWithError: Story = {
|
|
57
|
+
args: {
|
|
58
|
+
title: 'Behavior',
|
|
59
|
+
collapsible: true,
|
|
60
|
+
defaultOpen: false,
|
|
61
|
+
hasError: true,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const WithTitleAdornment: Story = {
|
|
66
|
+
args: {
|
|
67
|
+
title: 'Attached files',
|
|
68
|
+
description: 'Files this assistant can reference when answering.',
|
|
69
|
+
titleAdornment: <InfoSprinkle>Up to 20 files, 100MB each.</InfoSprinkle>,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Several sections stacked the way a real sidebar form composes them — a lead
|
|
75
|
+
* section, a divided follow-on, and a collapsible group — to show the shared
|
|
76
|
+
* heading/description/field rhythm holds across the whole panel.
|
|
77
|
+
*/
|
|
78
|
+
export const PanelComposition: Story = {
|
|
79
|
+
render: () => (
|
|
80
|
+
<VStack align="stretch" gap={8} maxW="480px">
|
|
81
|
+
<FormSection
|
|
82
|
+
title="Profile"
|
|
83
|
+
description="How this assistant introduces itself."
|
|
84
|
+
>
|
|
85
|
+
<FieldRow label="Display name">
|
|
86
|
+
<Input placeholder="Enter a name" />
|
|
87
|
+
</FieldRow>
|
|
88
|
+
<FieldRow label="Tagline" description="A short one-line summary.">
|
|
89
|
+
<Input placeholder="Enter a tagline" />
|
|
90
|
+
</FieldRow>
|
|
91
|
+
</FormSection>
|
|
92
|
+
|
|
93
|
+
<FormSection
|
|
94
|
+
title="Connectors"
|
|
95
|
+
description="Live data sources this assistant can query."
|
|
96
|
+
divider
|
|
97
|
+
action={
|
|
98
|
+
<Button size="sm" variant="ghost">
|
|
99
|
+
Add
|
|
100
|
+
</Button>
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
<FieldRow label="Default source">
|
|
104
|
+
<Input placeholder="Select a connector" />
|
|
105
|
+
</FieldRow>
|
|
106
|
+
</FormSection>
|
|
107
|
+
|
|
108
|
+
<FormSection title="Advanced" collapsible defaultOpen={false}>
|
|
109
|
+
<FieldRow label="Rate limit" description="Requests per minute.">
|
|
110
|
+
<Input placeholder="60" />
|
|
111
|
+
</FieldRow>
|
|
112
|
+
</FormSection>
|
|
113
|
+
</VStack>
|
|
114
|
+
),
|
|
115
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { FormSection } from './FormSection';
|
|
6
|
+
|
|
7
|
+
// Stub the layout chrome and the collapsible delegate so the test asserts the
|
|
8
|
+
// flat-vs-collapsible branching and prop forwarding, not logician internals.
|
|
9
|
+
vi.mock('@chakra-ui/react', () => ({
|
|
10
|
+
Box: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
11
|
+
Flex: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
12
|
+
VStack: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('@/components/Typography', () => ({
|
|
16
|
+
H5: ({ children }: { children?: ReactNode }) => <h5>{children}</h5>,
|
|
17
|
+
Subtext: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@/components/Workflow', () => ({
|
|
21
|
+
CollapsibleSection: ({
|
|
22
|
+
label,
|
|
23
|
+
hasError,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
label: string;
|
|
27
|
+
hasError?: boolean;
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
}) => (
|
|
30
|
+
<div
|
|
31
|
+
data-testid="collapsible"
|
|
32
|
+
data-label={label}
|
|
33
|
+
data-haserror={String(Boolean(hasError))}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
describe('FormSection', () => {
|
|
41
|
+
it('renders a flat section with title, description and children by default', () => {
|
|
42
|
+
render(
|
|
43
|
+
<FormSection title="Profile" description="How it introduces itself.">
|
|
44
|
+
<div>field</div>
|
|
45
|
+
</FormSection>
|
|
46
|
+
);
|
|
47
|
+
expect(
|
|
48
|
+
screen.getByRole('heading', { name: 'Profile' })
|
|
49
|
+
).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText('How it introduces itself.')).toBeInTheDocument();
|
|
51
|
+
expect(screen.getByText('field')).toBeInTheDocument();
|
|
52
|
+
expect(screen.queryByTestId('collapsible')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('delegates to CollapsibleSection when collapsible, forwarding title and hasError', () => {
|
|
56
|
+
render(
|
|
57
|
+
<FormSection title="Behavior" collapsible hasError>
|
|
58
|
+
<div>field</div>
|
|
59
|
+
</FormSection>
|
|
60
|
+
);
|
|
61
|
+
const collapsible = screen.getByTestId('collapsible');
|
|
62
|
+
expect(collapsible).toHaveAttribute('data-label', 'Behavior');
|
|
63
|
+
expect(collapsible).toHaveAttribute('data-haserror', 'true');
|
|
64
|
+
expect(screen.getByText('field')).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('stays flat when collapsible is set without a title', () => {
|
|
68
|
+
render(
|
|
69
|
+
<FormSection collapsible>
|
|
70
|
+
<div>field</div>
|
|
71
|
+
</FormSection>
|
|
72
|
+
);
|
|
73
|
+
expect(screen.queryByTestId('collapsible')).toBeNull();
|
|
74
|
+
expect(screen.getByText('field')).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders the inline title adornment in the flat section', () => {
|
|
78
|
+
render(
|
|
79
|
+
<FormSection
|
|
80
|
+
title="Files"
|
|
81
|
+
titleAdornment={<span data-testid="sprinkle">i</span>}
|
|
82
|
+
>
|
|
83
|
+
<div>field</div>
|
|
84
|
+
</FormSection>
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByRole('heading', { name: 'Files' })).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByTestId('sprinkle')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CollapsibleFormSection } from './CollapsibleFormSection';
|
|
2
|
+
import { FlatFormSection } from './FlatFormSection';
|
|
3
|
+
import { FormSectionProps } from './FormSection.types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A titled group of form fields with one consistent rhythm — the shared
|
|
7
|
+
* replacement for the studio panels' ad-hoc `GroupHeader`/`ModeSection` +
|
|
8
|
+
* per-tab `VStack` gaps (8/6/12) and the workflow inspector's per-node section
|
|
9
|
+
* headers. Renders flat by default; set `collapsible` (with a `title`) to render
|
|
10
|
+
* the logician `CollapsibleSection` accordion. `gap` standardizes the field
|
|
11
|
+
* spacing so sections read the same everywhere.
|
|
12
|
+
*
|
|
13
|
+
* Thin dispatcher over the two single-purpose variants so each has one return.
|
|
14
|
+
*/
|
|
15
|
+
export const FormSection = ({
|
|
16
|
+
collapsible = false,
|
|
17
|
+
defaultOpen = true,
|
|
18
|
+
hasError = false,
|
|
19
|
+
...rest
|
|
20
|
+
}: FormSectionProps) =>
|
|
21
|
+
collapsible && rest.title ? (
|
|
22
|
+
<CollapsibleFormSection
|
|
23
|
+
title={rest.title}
|
|
24
|
+
action={rest.action}
|
|
25
|
+
gap={rest.gap}
|
|
26
|
+
defaultOpen={defaultOpen}
|
|
27
|
+
hasError={hasError}
|
|
28
|
+
>
|
|
29
|
+
{rest.children}
|
|
30
|
+
</CollapsibleFormSection>
|
|
31
|
+
) : (
|
|
32
|
+
<FlatFormSection {...rest} />
|
|
33
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { BoxProps } from '@chakra-ui/react';
|
|
3
|
+
|
|
4
|
+
export interface FormSectionProps extends Omit<BoxProps, 'title'> {
|
|
5
|
+
/** Section heading. */
|
|
6
|
+
title?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Inline element rendered immediately after the heading (e.g. an
|
|
9
|
+
* `InfoSprinkle`). Flat variant only — the collapsible variant owns its own
|
|
10
|
+
* header. Distinct from `action`, which sits at the far right.
|
|
11
|
+
*/
|
|
12
|
+
titleAdornment?: ReactNode;
|
|
13
|
+
/** Secondary description under the heading. */
|
|
14
|
+
description?: ReactNode;
|
|
15
|
+
/** Right-aligned header slot (e.g. an add button or info-sprinkle trigger). */
|
|
16
|
+
action?: ReactNode;
|
|
17
|
+
/** Draw a top divider to separate this section from the previous one. */
|
|
18
|
+
divider?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Render as a collapsible accordion (delegates to logician `CollapsibleSection`).
|
|
21
|
+
* Requires `title`.
|
|
22
|
+
*/
|
|
23
|
+
collapsible?: boolean;
|
|
24
|
+
/** Initial expanded state when `collapsible`. Defaults to open. */
|
|
25
|
+
defaultOpen?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Flag a section that contains errors. When `collapsible` and collapsed, the
|
|
28
|
+
* chevron is replaced by a danger glyph so hidden errors aren't missed.
|
|
29
|
+
*/
|
|
30
|
+
hasError?: boolean;
|
|
31
|
+
/** Vertical gap between child fields. Defaults to the standard field rhythm. */
|
|
32
|
+
gap?: BoxProps['gap'];
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
}
|