@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,46 @@
|
|
|
1
|
+
import { Box, Flex } from '@chakra-ui/react';
|
|
2
|
+
|
|
3
|
+
import { FormLabel } from '@/components/FormLabel';
|
|
4
|
+
import { Subtext } from '@/components/Typography';
|
|
5
|
+
|
|
6
|
+
import { FieldLabelProps } from './FieldLabel.types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Label row for a form field: a semibold heading, an optional description, and
|
|
10
|
+
* an `endAdornment` slot on the right (count badge, action button, info-sprinkle
|
|
11
|
+
* trigger). The required asterisk is supplied automatically by the enclosing
|
|
12
|
+
* `Field` context when the field is `required` (see `FieldRow`).
|
|
13
|
+
*
|
|
14
|
+
* Presentational — pair it with `FieldRow` (or any logician `FormControl`) so
|
|
15
|
+
* the heading is programmatically associated with its control.
|
|
16
|
+
*/
|
|
17
|
+
export const FieldLabel = ({
|
|
18
|
+
heading,
|
|
19
|
+
description,
|
|
20
|
+
endAdornment,
|
|
21
|
+
mb = 0,
|
|
22
|
+
htmlFor,
|
|
23
|
+
}: FieldLabelProps) => {
|
|
24
|
+
return (
|
|
25
|
+
<Flex align="center" justify="space-between" gap={2} mb={mb} w="100%">
|
|
26
|
+
<Box>
|
|
27
|
+
{heading != null && (
|
|
28
|
+
<FormLabel
|
|
29
|
+
mb={0}
|
|
30
|
+
fontWeight="semibold"
|
|
31
|
+
color="fg.default"
|
|
32
|
+
htmlFor={htmlFor}
|
|
33
|
+
>
|
|
34
|
+
{heading}
|
|
35
|
+
</FormLabel>
|
|
36
|
+
)}
|
|
37
|
+
{description && (
|
|
38
|
+
<Subtext fontWeight="normal" color="fg.muted">
|
|
39
|
+
{description}
|
|
40
|
+
</Subtext>
|
|
41
|
+
)}
|
|
42
|
+
</Box>
|
|
43
|
+
{endAdornment}
|
|
44
|
+
</Flex>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface FieldLabelProps {
|
|
4
|
+
/** Label heading. */
|
|
5
|
+
heading: ReactNode;
|
|
6
|
+
/** Secondary description rendered under the heading. */
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
/**
|
|
9
|
+
* Inline element rendered to the right of the heading row (e.g. button, count
|
|
10
|
+
* badge, info-sprinkle popover trigger).
|
|
11
|
+
*/
|
|
12
|
+
endAdornment?: ReactNode;
|
|
13
|
+
/**
|
|
14
|
+
* Associates the label with a control by id. Normally supplied by the
|
|
15
|
+
* enclosing `Field` context (via `FieldRow`/`FormControl`); pass it explicitly
|
|
16
|
+
* only when using `FieldLabel` outside that context.
|
|
17
|
+
*/
|
|
18
|
+
htmlFor?: string;
|
|
19
|
+
/** Bottom margin in spacing units. */
|
|
20
|
+
mb?: number;
|
|
21
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
|
|
3
|
+
import { Input } from '@/components/Input';
|
|
4
|
+
import { SelectField } from '@/components/Select';
|
|
5
|
+
import { Textarea } from '@/components/Textarea';
|
|
6
|
+
|
|
7
|
+
import { FieldRow } from './FieldRow';
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof FieldRow> = {
|
|
10
|
+
title: 'Components/Form/FieldRow',
|
|
11
|
+
component: FieldRow,
|
|
12
|
+
args: {
|
|
13
|
+
label: 'Display name',
|
|
14
|
+
children: <Input placeholder="Enter a name" />,
|
|
15
|
+
},
|
|
16
|
+
parameters: {
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
'The composition keystone for a form field. Wraps a control in a ' +
|
|
21
|
+
'logician `FormControl` and lays out label, description, helper and ' +
|
|
22
|
+
'error consistently, delegating all label↔control / aria wiring to ' +
|
|
23
|
+
'the Chakra `Field` context.',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof FieldRow>;
|
|
31
|
+
|
|
32
|
+
export const Default: Story = {};
|
|
33
|
+
|
|
34
|
+
export const Required: Story = {
|
|
35
|
+
args: { required: true },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithDescription: Story = {
|
|
39
|
+
args: { description: 'Shown to teammates who can see this chatbot.' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const WithHelperText: Story = {
|
|
43
|
+
args: { helperText: 'Up to 40 characters.' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const WithError: Story = {
|
|
47
|
+
args: { required: true, error: 'Display name is required.' },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const WithEndAdornment: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
label: 'Recommended questions',
|
|
53
|
+
endAdornment: '2 / 5',
|
|
54
|
+
children: <Input placeholder="Add a question" />,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const Disabled: Story = {
|
|
59
|
+
args: { disabled: true },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const TextareaControl: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
label: 'System instructions',
|
|
65
|
+
helperText: 'Describe how the assistant should behave.',
|
|
66
|
+
children: <Textarea placeholder="You are a helpful assistant…" />,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const SelectControl: Story = {
|
|
71
|
+
args: {
|
|
72
|
+
label: 'Aspect ratio',
|
|
73
|
+
children: (
|
|
74
|
+
<SelectField
|
|
75
|
+
options={[
|
|
76
|
+
{ label: '16:9', value: '16:9' },
|
|
77
|
+
{ label: '9:16', value: '9:16' },
|
|
78
|
+
{ label: '1:1', value: '1:1' },
|
|
79
|
+
]}
|
|
80
|
+
value="16:9"
|
|
81
|
+
onChange={() => {}}
|
|
82
|
+
/>
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -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 { FieldRow } from './FieldRow';
|
|
6
|
+
|
|
7
|
+
// Expose the `invalid` value FieldRow computes onto the DOM so the derivation
|
|
8
|
+
// (error -> invalid, with an explicit `invalid` override winning) is assertable
|
|
9
|
+
// without a full Chakra Field/provider render. FieldRow now composes its label
|
|
10
|
+
// out of the design system's own FormControl/FormLabel/Typography modules, so
|
|
11
|
+
// mock those directly.
|
|
12
|
+
vi.mock('@/components/FormControl', () => ({
|
|
13
|
+
FormControl: ({
|
|
14
|
+
children,
|
|
15
|
+
invalid,
|
|
16
|
+
}: {
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
invalid?: boolean;
|
|
19
|
+
}) => <div data-invalid={String(Boolean(invalid))}>{children}</div>,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@/components/FormLabel', () => ({
|
|
23
|
+
FormLabel: ({ children }: { children?: ReactNode }) => (
|
|
24
|
+
<label>{children}</label>
|
|
25
|
+
),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('@/components/Typography', () => ({
|
|
29
|
+
Subtext: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('@chakra-ui/react', () => ({
|
|
33
|
+
Box: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
34
|
+
Flex: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
35
|
+
Field: {
|
|
36
|
+
HelperText: ({ children }: { children?: ReactNode }) => (
|
|
37
|
+
<p data-testid="helper">{children}</p>
|
|
38
|
+
),
|
|
39
|
+
ErrorText: ({ children }: { children?: ReactNode }) => (
|
|
40
|
+
<p data-testid="error">{children}</p>
|
|
41
|
+
),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const control = <input aria-label="name" />;
|
|
46
|
+
|
|
47
|
+
describe('FieldRow', () => {
|
|
48
|
+
it('renders label, control, helper and error slots', () => {
|
|
49
|
+
render(
|
|
50
|
+
<FieldRow label="Display name" helperText="Up to 40 chars" error="Nope">
|
|
51
|
+
{control}
|
|
52
|
+
</FieldRow>
|
|
53
|
+
);
|
|
54
|
+
expect(screen.getByText('Display name')).toBeInTheDocument();
|
|
55
|
+
expect(screen.getByLabelText('name')).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByTestId('helper')).toHaveTextContent('Up to 40 chars');
|
|
57
|
+
expect(screen.getByTestId('error')).toHaveTextContent('Nope');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('flips the field to invalid when an error is set', () => {
|
|
61
|
+
const { container } = render(
|
|
62
|
+
<FieldRow label="Name" error="Required">
|
|
63
|
+
{control}
|
|
64
|
+
</FieldRow>
|
|
65
|
+
);
|
|
66
|
+
expect(container.querySelector('[data-invalid="true"]')).not.toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('is not invalid without an error', () => {
|
|
70
|
+
const { container } = render(<FieldRow label="Name">{control}</FieldRow>);
|
|
71
|
+
expect(container.querySelector('[data-invalid="false"]')).not.toBeNull();
|
|
72
|
+
expect(screen.queryByTestId('error')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('lets an explicit invalid prop win over the error-derived value', () => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<FieldRow label="Name" error="Required" invalid={false}>
|
|
78
|
+
{control}
|
|
79
|
+
</FieldRow>
|
|
80
|
+
);
|
|
81
|
+
expect(container.querySelector('[data-invalid="false"]')).not.toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('omits the label row when there is no label or adornment', () => {
|
|
85
|
+
const { container } = render(<FieldRow>{control}</FieldRow>);
|
|
86
|
+
expect(container.querySelector('label')).toBeNull();
|
|
87
|
+
expect(screen.getByLabelText('name')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { FormControl } from '@/components/FormControl';
|
|
2
|
+
|
|
3
|
+
import { FieldError } from '../FieldError';
|
|
4
|
+
import { FieldHelp } from '../FieldHelp';
|
|
5
|
+
import { FieldLabel } from '../FieldLabel';
|
|
6
|
+
import { FieldRowProps } from './FieldRow.types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The composition keystone for a form field: wraps a control in a logician
|
|
10
|
+
* `FormControl` (Chakra `Field.Root`) and lays out its label, optional
|
|
11
|
+
* description/help, and error consistently. Because it delegates to the `Field`
|
|
12
|
+
* context, the label↔control association, the `required` asterisk, `aria-invalid`
|
|
13
|
+
* and the error/helper `aria-describedby` wiring are all handled automatically —
|
|
14
|
+
* callers never thread ids by hand.
|
|
15
|
+
*
|
|
16
|
+
* It is form-library agnostic: pass `value`/`onChange` controls directly (media
|
|
17
|
+
* forms) or render a form-connected adapter as the child (studio/admin forms). Set
|
|
18
|
+
* `error` to flip the field to invalid and surface the message.
|
|
19
|
+
*/
|
|
20
|
+
export const FieldRow = ({
|
|
21
|
+
label,
|
|
22
|
+
description,
|
|
23
|
+
endAdornment,
|
|
24
|
+
helperText,
|
|
25
|
+
error,
|
|
26
|
+
invalid,
|
|
27
|
+
children,
|
|
28
|
+
labelProps,
|
|
29
|
+
...rootProps
|
|
30
|
+
}: FieldRowProps) => {
|
|
31
|
+
return (
|
|
32
|
+
<FormControl invalid={invalid ?? Boolean(error)} {...rootProps}>
|
|
33
|
+
{(label != null || endAdornment != null) && (
|
|
34
|
+
<FieldLabel
|
|
35
|
+
heading={label}
|
|
36
|
+
description={description}
|
|
37
|
+
endAdornment={endAdornment}
|
|
38
|
+
{...labelProps}
|
|
39
|
+
/>
|
|
40
|
+
)}
|
|
41
|
+
{children}
|
|
42
|
+
{helperText != null && <FieldHelp>{helperText}</FieldHelp>}
|
|
43
|
+
<FieldError>{error}</FieldError>
|
|
44
|
+
</FormControl>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { FormControlProps } from '@/components/FormControl';
|
|
4
|
+
|
|
5
|
+
import { FieldLabelProps } from '../FieldLabel';
|
|
6
|
+
|
|
7
|
+
export interface FieldRowProps extends Omit<FormControlProps, 'label'> {
|
|
8
|
+
/** Field label heading. Omit for an unlabeled control row. */
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
/** Secondary description rendered under the label. */
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
/**
|
|
13
|
+
* Inline node at the right of the label row — a count badge, an action
|
|
14
|
+
* button, or a logician `InfoSprinkle` help popover.
|
|
15
|
+
*/
|
|
16
|
+
endAdornment?: ReactNode;
|
|
17
|
+
/** Always-visible helper text rendered under the control. */
|
|
18
|
+
helperText?: ReactNode;
|
|
19
|
+
/**
|
|
20
|
+
* Error message. When set (and `invalid` is not explicitly overridden) the
|
|
21
|
+
* field renders as invalid, so the control gets `aria-invalid` and the error
|
|
22
|
+
* is announced and associated via `aria-describedby`.
|
|
23
|
+
*/
|
|
24
|
+
error?: ReactNode;
|
|
25
|
+
/** The control (input/select/switch/…) this row wraps. */
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
/** Extra props forwarded to the inner `FieldLabel` (e.g. custom `mb`). */
|
|
28
|
+
labelProps?: Omit<
|
|
29
|
+
FieldLabelProps,
|
|
30
|
+
'heading' | 'description' | 'endAdornment'
|
|
31
|
+
>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Box } from '@chakra-ui/react';
|
|
2
|
+
import { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/Button';
|
|
5
|
+
import { Subtitle } from '@/components/Typography';
|
|
6
|
+
|
|
7
|
+
import { FormFooter } from './FormFooter';
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof FormFooter> = {
|
|
10
|
+
title: 'Components/Form/FormFooter',
|
|
11
|
+
component: FormFooter,
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<Box w="420px" bg="bg.canvas">
|
|
15
|
+
<Story />
|
|
16
|
+
</Box>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
parameters: {
|
|
20
|
+
docs: {
|
|
21
|
+
description: {
|
|
22
|
+
component:
|
|
23
|
+
'The sticky footer shell shared by the studio settings footer and ' +
|
|
24
|
+
'the image/video generation footers: an optional left `start` slot ' +
|
|
25
|
+
'against a right group of `meta` + `action`.',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof FormFooter>;
|
|
33
|
+
|
|
34
|
+
/** Studio settings footer shape: destructive start + saved-at meta + save action. */
|
|
35
|
+
export const WithStartMetaAndAction: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
start: (
|
|
38
|
+
<Button size="sm" variant="ghost" colorPalette="danger">
|
|
39
|
+
Delete
|
|
40
|
+
</Button>
|
|
41
|
+
),
|
|
42
|
+
meta: <Subtitle>Updated 2 minutes ago</Subtitle>,
|
|
43
|
+
action: (
|
|
44
|
+
<Button size="sm" variant="solid">
|
|
45
|
+
Save
|
|
46
|
+
</Button>
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Media generation footer shape: a single full-width action. */
|
|
52
|
+
export const FullWidthAction: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
action: (
|
|
55
|
+
<Button variant="solid" size="lg" w="100%">
|
|
56
|
+
Generate image · 12
|
|
57
|
+
</Button>
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const ActionOnly: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
action: (
|
|
65
|
+
<Button size="sm" variant="solid">
|
|
66
|
+
Save
|
|
67
|
+
</Button>
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { FormFooter } from './FormFooter';
|
|
6
|
+
|
|
7
|
+
// Surface the layout-driving props (justify / position) onto the DOM so the
|
|
8
|
+
// slot layout and sticky behavior are assertable without a Chakra provider.
|
|
9
|
+
vi.mock('@chakra-ui/react', () => ({
|
|
10
|
+
Flex: ({
|
|
11
|
+
children,
|
|
12
|
+
justify,
|
|
13
|
+
position,
|
|
14
|
+
}: {
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
justify?: string;
|
|
17
|
+
position?: string;
|
|
18
|
+
}) => (
|
|
19
|
+
<div data-justify={justify ?? ''} data-position={position ?? ''}>
|
|
20
|
+
{children}
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const outer = (container: HTMLElement) => container.firstChild as HTMLElement;
|
|
26
|
+
|
|
27
|
+
describe('FormFooter', () => {
|
|
28
|
+
it('renders start, meta and action slots', () => {
|
|
29
|
+
render(
|
|
30
|
+
<FormFooter
|
|
31
|
+
start={<button>Delete</button>}
|
|
32
|
+
meta={<span>Updated now</span>}
|
|
33
|
+
action={<button>Save</button>}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
expect(screen.getByText('Delete')).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByText('Updated now')).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText('Save')).toBeInTheDocument();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('justifies space-between when a start slot is present', () => {
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<FormFooter
|
|
44
|
+
start={<button>Delete</button>}
|
|
45
|
+
action={<button>Save</button>}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
expect(outer(container)).toHaveAttribute('data-justify', 'space-between');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('justifies flex-end when there is no start slot (media full-width action)', () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<FormFooter action={<button>Generate</button>} />
|
|
54
|
+
);
|
|
55
|
+
expect(outer(container)).toHaveAttribute('data-justify', 'flex-end');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('is sticky by default and drops sticky when disabled', () => {
|
|
59
|
+
const { container, rerender } = render(
|
|
60
|
+
<FormFooter action={<button>Save</button>} />
|
|
61
|
+
);
|
|
62
|
+
expect(outer(container)).toHaveAttribute('data-position', 'sticky');
|
|
63
|
+
|
|
64
|
+
rerender(<FormFooter action={<button>Save</button>} sticky={false} />);
|
|
65
|
+
expect(outer(container)).toHaveAttribute('data-position', '');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Flex } from '@chakra-ui/react';
|
|
2
|
+
|
|
3
|
+
import { FormFooterProps } from './FormFooter.types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The sticky footer shell shared by the form-in-sidebar surfaces — the studio
|
|
7
|
+
* settings footer and the image/video generation footers. It standardizes the
|
|
8
|
+
* chrome (sticky bottom, `bg.surface`, hairline top border) and lays out an
|
|
9
|
+
* optional left `start` slot against a right group of `meta` + `action`. The
|
|
10
|
+
* action's own loading/disabled state lives on the button passed in, so this is
|
|
11
|
+
* a pure layout shell.
|
|
12
|
+
*/
|
|
13
|
+
export const FormFooter = ({
|
|
14
|
+
start,
|
|
15
|
+
meta,
|
|
16
|
+
action,
|
|
17
|
+
sticky = true,
|
|
18
|
+
...rest
|
|
19
|
+
}: FormFooterProps) => {
|
|
20
|
+
return (
|
|
21
|
+
<Flex
|
|
22
|
+
{...(sticky ? { position: 'sticky', bottom: 0 } : { flexShrink: 0 })}
|
|
23
|
+
p={4}
|
|
24
|
+
borderTopWidth="1px"
|
|
25
|
+
borderColor="border.subtle"
|
|
26
|
+
w="100%"
|
|
27
|
+
bgColor="bg.surface"
|
|
28
|
+
justify={start ? 'space-between' : 'flex-end'}
|
|
29
|
+
align="center"
|
|
30
|
+
{...rest}
|
|
31
|
+
>
|
|
32
|
+
{start}
|
|
33
|
+
{(meta != null || action != null) && (
|
|
34
|
+
<Flex
|
|
35
|
+
align="center"
|
|
36
|
+
justify="flex-end"
|
|
37
|
+
gap={4}
|
|
38
|
+
flex={start ? undefined : 1}
|
|
39
|
+
>
|
|
40
|
+
{meta}
|
|
41
|
+
{action}
|
|
42
|
+
</Flex>
|
|
43
|
+
)}
|
|
44
|
+
</Flex>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { FlexProps } from '@chakra-ui/react';
|
|
3
|
+
|
|
4
|
+
export interface FormFooterProps extends Omit<FlexProps, 'children'> {
|
|
5
|
+
/**
|
|
6
|
+
* Left-aligned slot — typically a destructive or back action (e.g. delete).
|
|
7
|
+
* When present the footer justifies its content `space-between`; otherwise the
|
|
8
|
+
* right group fills the width.
|
|
9
|
+
*/
|
|
10
|
+
start?: ReactNode;
|
|
11
|
+
/**
|
|
12
|
+
* Right-aligned metadata rendered before the action — a saved-at timestamp or
|
|
13
|
+
* a credit cost.
|
|
14
|
+
*/
|
|
15
|
+
meta?: ReactNode;
|
|
16
|
+
/** The primary action, right-aligned. */
|
|
17
|
+
action?: ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* Pin the footer to the bottom of its scroll container. Defaults to true; set
|
|
20
|
+
* false for a footer that flows at the end of non-scrolling content.
|
|
21
|
+
*/
|
|
22
|
+
sticky?: boolean;
|
|
23
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Box } from '@chakra-ui/react';
|
|
2
|
+
import { Meta, StoryObj } from '@storybook/react';
|
|
3
|
+
|
|
4
|
+
import { FormModalFooter } from './FormModalFooter';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof FormModalFooter> = {
|
|
7
|
+
title: 'Components/Form/FormModalFooter',
|
|
8
|
+
component: FormModalFooter,
|
|
9
|
+
args: {
|
|
10
|
+
onCancel: () => {},
|
|
11
|
+
onSubmit: () => {},
|
|
12
|
+
cancelLabel: 'Cancel',
|
|
13
|
+
submitLabel: 'Save',
|
|
14
|
+
},
|
|
15
|
+
decorators: [
|
|
16
|
+
(Story) => (
|
|
17
|
+
<Box w="480px" bg="bg.canvas">
|
|
18
|
+
<Story />
|
|
19
|
+
</Box>
|
|
20
|
+
),
|
|
21
|
+
],
|
|
22
|
+
parameters: {
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
'The action footer shared by form-in-a-modal surfaces — the form ' +
|
|
27
|
+
'analog of a confirm-modal footer. Standardizes the optional delete ' +
|
|
28
|
+
'+ Cancel + primary-submit trio every create/edit modal was ' +
|
|
29
|
+
'hand-rolling. Copy-agnostic: `cancelLabel` is required and labels ' +
|
|
30
|
+
'are rendered verbatim.',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof FormModalFooter>;
|
|
38
|
+
|
|
39
|
+
/** The common shape: Cancel + primary submit. */
|
|
40
|
+
export const Default: Story = {};
|
|
41
|
+
|
|
42
|
+
/** Edit modal shape: a far-left destructive Delete beside Cancel + submit. */
|
|
43
|
+
export const WithDelete: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
onDelete: () => {},
|
|
46
|
+
deleteLabel: 'Delete',
|
|
47
|
+
submitLabel: 'Save changes',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Mid-request: spinner on submit, every button disabled. */
|
|
52
|
+
export const Submitting: Story = {
|
|
53
|
+
args: { isSubmitting: true },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Gated submit (e.g. a required field is empty). */
|
|
57
|
+
export const SubmitDisabled: Story = {
|
|
58
|
+
args: { submitDisabled: true },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Scroll-inside modal: the bordered separator against a pinned footer. */
|
|
62
|
+
export const Bordered: Story = {
|
|
63
|
+
args: { bordered: true, onDelete: () => {}, deleteLabel: 'Delete' },
|
|
64
|
+
};
|