@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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { REGEXP_ONLY_DIGITS, useInputOtpContext } from './index';
|
|
6
|
+
import { InputOtp } from './InputOtp';
|
|
7
|
+
|
|
8
|
+
// `input-otp` uses ResizeObserver to align its hidden input over the slots;
|
|
9
|
+
// jsdom doesn't ship one, so stub a no-op implementation for the suite.
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
'ResizeObserver',
|
|
13
|
+
class {
|
|
14
|
+
observe() {}
|
|
15
|
+
unobserve() {}
|
|
16
|
+
disconnect() {}
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const SixDigit = (props: Partial<React.ComponentProps<typeof InputOtp>> = {}) => (
|
|
22
|
+
<InputOtp aria-label="code" maxLength={6} {...props}>
|
|
23
|
+
<InputOtp.Group>
|
|
24
|
+
<InputOtp.Slot index={0} />
|
|
25
|
+
<InputOtp.Slot index={1} />
|
|
26
|
+
<InputOtp.Slot index={2} />
|
|
27
|
+
<InputOtp.Slot index={3} />
|
|
28
|
+
<InputOtp.Slot index={4} />
|
|
29
|
+
<InputOtp.Slot index={5} />
|
|
30
|
+
</InputOtp.Group>
|
|
31
|
+
</InputOtp>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const getInput = () => screen.getByLabelText('code') as HTMLInputElement;
|
|
35
|
+
|
|
36
|
+
describe('<InputOtp />', () => {
|
|
37
|
+
it('renders one slot per maxLength position', () => {
|
|
38
|
+
render(<SixDigit />);
|
|
39
|
+
const slots = document.querySelectorAll('[data-slot-index]');
|
|
40
|
+
expect(slots).toHaveLength(6);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('displays typed characters in their slots', () => {
|
|
44
|
+
const Controlled = () => {
|
|
45
|
+
const [value, setValue] = useState('');
|
|
46
|
+
return <SixDigit value={value} onChange={setValue} />;
|
|
47
|
+
};
|
|
48
|
+
render(<Controlled />);
|
|
49
|
+
fireEvent.change(getInput(), { target: { value: '123' } });
|
|
50
|
+
const slots = document.querySelectorAll('[data-slot-index]');
|
|
51
|
+
expect(slots[0]?.textContent).toBe('1');
|
|
52
|
+
expect(slots[1]?.textContent).toBe('2');
|
|
53
|
+
expect(slots[2]?.textContent).toBe('3');
|
|
54
|
+
expect(slots[3]?.textContent).toBe('');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('fires onComplete exactly once when maxLength is reached', () => {
|
|
58
|
+
const onComplete = vi.fn();
|
|
59
|
+
render(<SixDigit onComplete={onComplete} />);
|
|
60
|
+
fireEvent.change(getInput(), { target: { value: '123456' } });
|
|
61
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(onComplete).toHaveBeenCalledWith('123456');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('re-fires onComplete after editing and retyping a completed code', () => {
|
|
66
|
+
const onComplete = vi.fn();
|
|
67
|
+
const Controlled = () => {
|
|
68
|
+
const [value, setValue] = useState('');
|
|
69
|
+
return <SixDigit value={value} onChange={setValue} onComplete={onComplete} />;
|
|
70
|
+
};
|
|
71
|
+
render(<Controlled />);
|
|
72
|
+
fireEvent.change(getInput(), { target: { value: '123456' } });
|
|
73
|
+
fireEvent.change(getInput(), { target: { value: '12345' } });
|
|
74
|
+
fireEvent.change(getInput(), { target: { value: '123459' } });
|
|
75
|
+
expect(onComplete).toHaveBeenCalledTimes(2);
|
|
76
|
+
expect(onComplete).toHaveBeenLastCalledWith('123459');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('truncates input beyond maxLength', () => {
|
|
80
|
+
const onChange = vi.fn();
|
|
81
|
+
render(<SixDigit onChange={onChange} />);
|
|
82
|
+
fireEvent.change(getInput(), { target: { value: '1234567890' } });
|
|
83
|
+
expect(onChange).toHaveBeenLastCalledWith('123456');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects characters that fail the pattern', () => {
|
|
87
|
+
const onChange = vi.fn();
|
|
88
|
+
render(<SixDigit pattern={REGEXP_ONLY_DIGITS} onChange={onChange} />);
|
|
89
|
+
fireEvent.change(getInput(), { target: { value: 'abc' } });
|
|
90
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
91
|
+
expect(getInput().value).toBe('');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('marks the input as aria-invalid when state="error"', () => {
|
|
95
|
+
render(<SixDigit state="error" />);
|
|
96
|
+
expect(getInput().getAttribute('aria-invalid')).toBe('true');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not set aria-invalid in the default state', () => {
|
|
100
|
+
render(<SixDigit />);
|
|
101
|
+
expect(getInput().getAttribute('aria-invalid')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('blocks input when disabled', () => {
|
|
105
|
+
render(<SixDigit disabled />);
|
|
106
|
+
expect(getInput().disabled).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('seeds the value from defaultValue', () => {
|
|
110
|
+
render(<SixDigit defaultValue="42" />);
|
|
111
|
+
const slots = document.querySelectorAll('[data-slot-index]');
|
|
112
|
+
expect(slots[0]?.textContent).toBe('4');
|
|
113
|
+
expect(slots[1]?.textContent).toBe('2');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('exposes the render context to custom children via useInputOtpContext', () => {
|
|
117
|
+
const FirstSlotChar = () => {
|
|
118
|
+
const { slots } = useInputOtpContext();
|
|
119
|
+
return <span data-testid="first-slot-char">{slots[0]?.char ?? ''}</span>;
|
|
120
|
+
};
|
|
121
|
+
const Controlled = () => {
|
|
122
|
+
const [value, setValue] = useState('');
|
|
123
|
+
return (
|
|
124
|
+
<InputOtp aria-label="code" maxLength={6} value={value} onChange={setValue}>
|
|
125
|
+
<FirstSlotChar />
|
|
126
|
+
</InputOtp>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
render(<Controlled />);
|
|
130
|
+
fireEvent.change(getInput(), { target: { value: '7' } });
|
|
131
|
+
expect(screen.getByTestId('first-slot-char').textContent).toBe('7');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws if useInputOtpContext is called outside <InputOtp>', () => {
|
|
135
|
+
const Bare = () => {
|
|
136
|
+
useInputOtpContext();
|
|
137
|
+
return null;
|
|
138
|
+
};
|
|
139
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
140
|
+
expect(() => render(<Bare />)).toThrow(/inside <InputOtp>/);
|
|
141
|
+
consoleError.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('renders a custom separator when provided', () => {
|
|
145
|
+
render(
|
|
146
|
+
<InputOtp aria-label="code" maxLength={6}>
|
|
147
|
+
<InputOtp.Group>
|
|
148
|
+
<InputOtp.Slot index={0} />
|
|
149
|
+
<InputOtp.Slot index={1} />
|
|
150
|
+
<InputOtp.Slot index={2} />
|
|
151
|
+
</InputOtp.Group>
|
|
152
|
+
<InputOtp.Separator>·</InputOtp.Separator>
|
|
153
|
+
<InputOtp.Group>
|
|
154
|
+
<InputOtp.Slot index={3} />
|
|
155
|
+
<InputOtp.Slot index={4} />
|
|
156
|
+
<InputOtp.Slot index={5} />
|
|
157
|
+
</InputOtp.Group>
|
|
158
|
+
</InputOtp>,
|
|
159
|
+
);
|
|
160
|
+
expect(screen.getByRole('separator')).toHaveTextContent('·');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode, Ref } from 'react';
|
|
2
|
+
import { OTPInput, REGEXP_ONLY_DIGITS } from 'input-otp';
|
|
3
|
+
|
|
4
|
+
import { cn } from '../../utils';
|
|
5
|
+
|
|
6
|
+
import { type InputOtpSizing, type InputOtpState, InputOtpVariantContext } from './context';
|
|
7
|
+
import { InputOtpGroup } from './InputOtpGroup';
|
|
8
|
+
import { InputOtpSeparator } from './InputOtpSeparator';
|
|
9
|
+
import { InputOtpSlot } from './InputOtpSlot';
|
|
10
|
+
|
|
11
|
+
// The `OTPInput` props union pairs `render` with `children?: never` (and vice versa).
|
|
12
|
+
// `Extract` picks the children-arm so passing `render` is a compile error.
|
|
13
|
+
type OtpInputProps = ComponentProps<typeof OTPInput>;
|
|
14
|
+
type OtpInputChildrenProps = Omit<
|
|
15
|
+
Extract<OtpInputProps, { children: ReactNode }>,
|
|
16
|
+
'containerClassName'
|
|
17
|
+
>;
|
|
18
|
+
|
|
19
|
+
type RootOwnProps = {
|
|
20
|
+
/** Visual size of each slot. Defaults to `md`. Matches InputGroup heights (sm/md/lg ≈ 32 / 36 / 40px). */
|
|
21
|
+
sizing?: InputOtpSizing;
|
|
22
|
+
/** Drives the error ring/border on each slot and `aria-invalid` on the inner input. */
|
|
23
|
+
state?: InputOtpState;
|
|
24
|
+
/** Class for the outer flex container that holds groups + separators. */
|
|
25
|
+
containerClassName?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type RootProps = OtpInputChildrenProps & RootOwnProps;
|
|
29
|
+
|
|
30
|
+
const Root = ({
|
|
31
|
+
ref,
|
|
32
|
+
sizing = 'md',
|
|
33
|
+
state = 'default',
|
|
34
|
+
pattern = REGEXP_ONLY_DIGITS,
|
|
35
|
+
containerClassName,
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...rest
|
|
39
|
+
}: RootProps & { ref?: Ref<HTMLInputElement> }) => (
|
|
40
|
+
<InputOtpVariantContext value={{ sizing, state }}>
|
|
41
|
+
<OTPInput
|
|
42
|
+
{...rest}
|
|
43
|
+
ref={ref}
|
|
44
|
+
pattern={pattern}
|
|
45
|
+
aria-invalid={state === 'error' || undefined}
|
|
46
|
+
containerClassName={cn(
|
|
47
|
+
'tw-flex tw-items-center tw-gap-2 has-[:disabled]:tw-opacity-50',
|
|
48
|
+
containerClassName,
|
|
49
|
+
)}
|
|
50
|
+
className={cn('disabled:tw-cursor-not-allowed', className)}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</OTPInput>
|
|
54
|
+
</InputOtpVariantContext>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
export const InputOtp = Object.assign(Root, {
|
|
58
|
+
Group: InputOtpGroup,
|
|
59
|
+
Slot: InputOtpSlot,
|
|
60
|
+
Separator: InputOtpSeparator,
|
|
61
|
+
});
|
|
62
|
+
export type { RootProps as InputOtpProps };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ComponentProps, Ref } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../utils';
|
|
4
|
+
|
|
5
|
+
type GroupProps = ComponentProps<'div'>;
|
|
6
|
+
|
|
7
|
+
export const InputOtpGroup = ({
|
|
8
|
+
ref,
|
|
9
|
+
className,
|
|
10
|
+
...props
|
|
11
|
+
}: GroupProps & { ref?: Ref<HTMLDivElement> }) => (
|
|
12
|
+
<div ref={ref} className={cn('tw-flex tw-items-center tw-gap-2', className)} {...props} />
|
|
13
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ComponentProps, Ref } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Minus } from '../../icons';
|
|
4
|
+
import { cn } from '../../utils';
|
|
5
|
+
|
|
6
|
+
type SeparatorProps = ComponentProps<'div'>;
|
|
7
|
+
|
|
8
|
+
export const InputOtpSeparator = ({
|
|
9
|
+
ref,
|
|
10
|
+
children,
|
|
11
|
+
className,
|
|
12
|
+
...props
|
|
13
|
+
}: SeparatorProps & { ref?: Ref<HTMLDivElement> }) => (
|
|
14
|
+
<div
|
|
15
|
+
ref={ref}
|
|
16
|
+
role="separator"
|
|
17
|
+
aria-orientation="vertical"
|
|
18
|
+
className={cn('tw-flex tw-items-center tw-text-muted-foreground', className)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{children ?? <Minus className="tw-size-4" />}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
// Slot sizes mirror InputGroup (`small` / `medium` / `default` ≈ 32 / 36 / 40px) so an OTP
|
|
4
|
+
// field can sit next to a regular text input without the row jumping.
|
|
5
|
+
export const slotVariants = cva(
|
|
6
|
+
[
|
|
7
|
+
'tw-relative',
|
|
8
|
+
'tw-flex',
|
|
9
|
+
'tw-items-center',
|
|
10
|
+
'tw-justify-center',
|
|
11
|
+
'tw-border',
|
|
12
|
+
'tw-border-input',
|
|
13
|
+
'tw-bg-background',
|
|
14
|
+
'tw-text-foreground',
|
|
15
|
+
'tw-font-medium',
|
|
16
|
+
'tw-tabular-nums',
|
|
17
|
+
'tw-rounded-lg',
|
|
18
|
+
'tw-transition',
|
|
19
|
+
'tw-outline-none',
|
|
20
|
+
// active slot sits above its neighbours so its ring isn't clipped
|
|
21
|
+
'data-[active=true]:tw-z-10',
|
|
22
|
+
'data-[active=true]:tw-border-primary',
|
|
23
|
+
'data-[active=true]:tw-ring-2',
|
|
24
|
+
'data-[active=true]:tw-ring-primary/50',
|
|
25
|
+
],
|
|
26
|
+
{
|
|
27
|
+
defaultVariants: {
|
|
28
|
+
sizing: 'md',
|
|
29
|
+
state: 'default',
|
|
30
|
+
},
|
|
31
|
+
variants: {
|
|
32
|
+
sizing: {
|
|
33
|
+
sm: ['tw-h-8', 'tw-w-8', 'tw-text-sm'],
|
|
34
|
+
md: ['tw-h-9', 'tw-w-9', 'tw-text-base'],
|
|
35
|
+
lg: ['tw-h-10', 'tw-w-10', 'tw-text-lg'],
|
|
36
|
+
},
|
|
37
|
+
// `!` forces the border colour over the base `tw-border-input` once tailwind-merge dedupes.
|
|
38
|
+
// Matches the pattern InputGroup uses for its argo error state.
|
|
39
|
+
state: {
|
|
40
|
+
default: [],
|
|
41
|
+
error: [
|
|
42
|
+
'!tw-border-destructive',
|
|
43
|
+
'tw-bg-destructive/5',
|
|
44
|
+
'data-[active=true]:!tw-border-destructive',
|
|
45
|
+
'data-[active=true]:tw-ring-destructive/40',
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export type SlotVariants = VariantProps<typeof slotVariants>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentProps, Ref } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../utils';
|
|
4
|
+
|
|
5
|
+
import { useInputOtpRenderContext, useInputOtpVariant } from './context';
|
|
6
|
+
import { slotVariants } from './InputOtpSlot.styles';
|
|
7
|
+
|
|
8
|
+
type SlotProps = Omit<ComponentProps<'div'>, 'children'> & {
|
|
9
|
+
index: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const InputOtpSlot = ({
|
|
13
|
+
ref,
|
|
14
|
+
index,
|
|
15
|
+
className,
|
|
16
|
+
...props
|
|
17
|
+
}: SlotProps & { ref?: Ref<HTMLDivElement> }) => {
|
|
18
|
+
const { slots } = useInputOtpRenderContext();
|
|
19
|
+
const { sizing, state } = useInputOtpVariant();
|
|
20
|
+
const slot = slots[index];
|
|
21
|
+
const char = slot?.char ?? null;
|
|
22
|
+
const isActive = slot?.isActive ?? false;
|
|
23
|
+
const hasFakeCaret = slot?.hasFakeCaret ?? false;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
ref={ref}
|
|
28
|
+
data-active={isActive || undefined}
|
|
29
|
+
data-slot-index={index}
|
|
30
|
+
className={cn(slotVariants({ sizing, state }), className)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
{char}
|
|
34
|
+
{hasFakeCaret && (
|
|
35
|
+
<div
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
className="tw-pointer-events-none tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"
|
|
38
|
+
>
|
|
39
|
+
<div className="tw-h-1/2 tw-w-px tw-animate-caret-blink tw-bg-foreground" />
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createContext, use } from 'react';
|
|
2
|
+
import { OTPInputContext } from 'input-otp';
|
|
3
|
+
|
|
4
|
+
import { type SlotVariants } from './InputOtpSlot.styles';
|
|
5
|
+
|
|
6
|
+
type Sizing = NonNullable<SlotVariants['sizing']>;
|
|
7
|
+
type State = NonNullable<SlotVariants['state']>;
|
|
8
|
+
|
|
9
|
+
type InputOtpVariantContextValue = {
|
|
10
|
+
sizing: Sizing;
|
|
11
|
+
state: State;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const InputOtpVariantContext = createContext<InputOtpVariantContextValue>({
|
|
15
|
+
sizing: 'md',
|
|
16
|
+
state: 'default',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const useInputOtpVariant = () => use(InputOtpVariantContext);
|
|
20
|
+
|
|
21
|
+
export const useInputOtpRenderContext = () => use(OTPInputContext);
|
|
22
|
+
|
|
23
|
+
export type { Sizing as InputOtpSizing, State as InputOtpState };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { use } from 'react';
|
|
2
|
+
import { OTPInputContext, type RenderProps } from 'input-otp';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
REGEXP_ONLY_CHARS,
|
|
6
|
+
REGEXP_ONLY_DIGITS,
|
|
7
|
+
REGEXP_ONLY_DIGITS_AND_CHARS,
|
|
8
|
+
type SlotProps as InputOtpRenderSlotProps,
|
|
9
|
+
} from 'input-otp';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Access the `input-otp` render context (slots, focus, hover) — use this inside a custom
|
|
13
|
+
* child of `<InputOtp>` to render slots yourself. Throws if called outside `<InputOtp>`.
|
|
14
|
+
*/
|
|
15
|
+
export const useInputOtpContext = (): RenderProps => {
|
|
16
|
+
const ctx = use(OTPInputContext);
|
|
17
|
+
if (!ctx.slots) {
|
|
18
|
+
throw new Error('useInputOtpContext must be used inside <InputOtp>.');
|
|
19
|
+
}
|
|
20
|
+
return ctx;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export * from './InputOtp';
|
|
@@ -48,7 +48,7 @@ either variant.
|
|
|
48
48
|
## Rules from the patterns
|
|
49
49
|
|
|
50
50
|
- **Index**: no breadcrumbs, no overflow menu, no search/filter/sort. Search
|
|
51
|
-
belongs in `
|
|
51
|
+
belongs in `PageToolbar`.
|
|
52
52
|
- **Detail**: breadcrumbs are required (use real entity names; last item is
|
|
53
53
|
not a link). Overflow menu lives on the breadcrumbs row, never in the title
|
|
54
54
|
row.
|
|
@@ -18,6 +18,7 @@ import { DropdownMenu } from '../../components/DropdownMenu';
|
|
|
18
18
|
import { MarkdownEditor } from '../../components/MarkdownEditor';
|
|
19
19
|
import { Tabs, TabsContent } from '../../components/Tabs';
|
|
20
20
|
import { PageLayout } from '../PageLayout';
|
|
21
|
+
import { PageToolbar } from '../PageToolbar';
|
|
21
22
|
import { SplitButton } from '../SplitButton';
|
|
22
23
|
|
|
23
24
|
import { PageHeader } from './PageHeader';
|
|
@@ -69,9 +70,11 @@ export const IndexFlows: Story = {
|
|
|
69
70
|
</>
|
|
70
71
|
}
|
|
71
72
|
/>
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
|
|
73
|
+
<PageToolbar>
|
|
74
|
+
<PageToolbar.Search>
|
|
75
|
+
<Placeholder label="Search input + filter pills" height="tw-h-10" />
|
|
76
|
+
</PageToolbar.Search>
|
|
77
|
+
</PageToolbar>
|
|
75
78
|
<PageLayout.Content>
|
|
76
79
|
<Placeholder label="EntityList" height="tw-h-96" />
|
|
77
80
|
</PageLayout.Content>
|
|
@@ -11,7 +11,7 @@ import { descriptionClass, titleClass, titleRowClass } from './styles';
|
|
|
11
11
|
// - At most one primary `action`; pair with the `documentation` icon button.
|
|
12
12
|
/**
|
|
13
13
|
* Props for `PageHeader.Index`. Index pages have no breadcrumbs and no overflow menu — search and filters
|
|
14
|
-
* belong in `
|
|
14
|
+
* belong in `PageToolbar`, not here.
|
|
15
15
|
*/
|
|
16
16
|
type PageHeaderIndexProps = Omit<ComponentPropsWithoutRef<'header'>, 'title'> & {
|
|
17
17
|
title: ReactNode;
|
|
@@ -35,7 +35,7 @@ type PageHeaderIndexProps = Omit<ComponentPropsWithoutRef<'header'>, 'title'> &
|
|
|
35
35
|
* **When NOT to use:** for single-entity detail pages use `PageHeader.Detail`; for layouts that don't fit
|
|
36
36
|
* either pattern host custom JSX in `PageLayout.Header` directly.
|
|
37
37
|
* **Composition:** nest inside `PageLayout.Header`. Always pass `Button argo` into `action` and
|
|
38
|
-
* `documentation`. Search and filter chips belong in `
|
|
38
|
+
* `documentation`. Search and filter chips belong in `PageToolbar`, not here.
|
|
39
39
|
*/
|
|
40
40
|
export const PageHeaderIndex = ({
|
|
41
41
|
ref,
|
|
@@ -24,7 +24,7 @@ PageLayout (Root) ← max-width container, vertical rhythm (gap-4)
|
|
|
24
24
|
├── PageLayout.Breadcrumbs ← Detail pattern only; usually composed by PageHeader.Detail
|
|
25
25
|
├── PageLayout.Header ← host PageHeader.Index / PageHeader.Detail (or custom JSX)
|
|
26
26
|
├── PageLayout.Tabs ← escape hatch for routing-based tab rows
|
|
27
|
-
├──
|
|
27
|
+
├── PageToolbar ← search / filter pills (NEVER in header) — separate block
|
|
28
28
|
├── PageLayout.Content ← main slot
|
|
29
29
|
└── PageLayout.Footer ← bottom action bar (form submit, `sticky` variant)
|
|
30
30
|
```
|
|
@@ -42,7 +42,7 @@ All slots take any `ReactNode` — no structured props live on the slot itself.
|
|
|
42
42
|
<PageLayout.Header>
|
|
43
43
|
<PageHeader.Index title="Storage" metadata="3 buckets and 39 tables" … />
|
|
44
44
|
</PageLayout.Header>
|
|
45
|
-
<
|
|
45
|
+
<PageToolbar>{searchAndFilters}</PageToolbar>
|
|
46
46
|
<PageLayout.Content>{list}</PageLayout.Content>
|
|
47
47
|
</PageLayout>
|
|
48
48
|
|
|
@@ -73,7 +73,7 @@ provides the chrome.
|
|
|
73
73
|
- **Index pages do not show breadcrumbs.** Skip `PageLayout.Breadcrumbs`
|
|
74
74
|
entirely on top-level list pages.
|
|
75
75
|
- **No search / filters / sorting in the header** — those belong in
|
|
76
|
-
`
|
|
76
|
+
`PageToolbar`.
|
|
77
77
|
|
|
78
78
|
## Component rules
|
|
79
79
|
|
|
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
|
3
3
|
import { Breadcrumb } from '../../components/Breadcrumb';
|
|
4
4
|
import { Button } from '../../components/Button';
|
|
5
5
|
import { Tabs, TabsContent } from '../../components/Tabs';
|
|
6
|
+
import { PageToolbar } from '../PageToolbar';
|
|
6
7
|
|
|
7
8
|
import { PageLayout } from './PageLayout';
|
|
8
9
|
|
|
@@ -15,7 +16,7 @@ export default {
|
|
|
15
16
|
docs: {
|
|
16
17
|
description: {
|
|
17
18
|
component:
|
|
18
|
-
'Compound block that gives every kbc-ui page the same shape — max-width container, vertical rhythm, and slots for `Breadcrumbs`, `Header`, `Tabs`, `
|
|
19
|
+
'Compound block that gives every kbc-ui page the same shape — max-width container, vertical rhythm, and slots for `Breadcrumbs`, `Header`, `Tabs`, `Content`, `Footer`. The slots are intentionally thin — for the structured Index / Detail page-header patterns reach for the `PageHeader` block, and for the search / filter row reach for the `PageToolbar` block. The page-navigation `Sidebar` lives **outside** `PageLayout` in the app shell.',
|
|
19
20
|
},
|
|
20
21
|
},
|
|
21
22
|
},
|
|
@@ -53,9 +54,11 @@ export const Anatomy: Story = {
|
|
|
53
54
|
<Placeholder label="PageHeader.Index / PageHeader.Detail goes here" height="tw-h-24" />
|
|
54
55
|
</PageLayout.Header>
|
|
55
56
|
|
|
56
|
-
<
|
|
57
|
-
<
|
|
58
|
-
|
|
57
|
+
<PageToolbar>
|
|
58
|
+
<PageToolbar.Search>
|
|
59
|
+
<Placeholder label="Search input + filter pills" height="tw-h-10" />
|
|
60
|
+
</PageToolbar.Search>
|
|
61
|
+
</PageToolbar>
|
|
59
62
|
|
|
60
63
|
<PageLayout.Content>
|
|
61
64
|
<Placeholder label="EntityList / Table" height="tw-h-96" />
|
|
@@ -75,21 +75,6 @@ const Tabs = ({
|
|
|
75
75
|
</div>
|
|
76
76
|
);
|
|
77
77
|
|
|
78
|
-
const Toolbar = ({
|
|
79
|
-
ref,
|
|
80
|
-
className,
|
|
81
|
-
children,
|
|
82
|
-
...props
|
|
83
|
-
}: ComponentPropsWithoutRef<'div'> & { ref?: Ref<ElementRef<'div'>> }) => (
|
|
84
|
-
<div
|
|
85
|
-
ref={ref}
|
|
86
|
-
className={cn('tw-flex tw-w-full tw-flex-wrap tw-items-center tw-gap-3', className)}
|
|
87
|
-
{...props}
|
|
88
|
-
>
|
|
89
|
-
{children}
|
|
90
|
-
</div>
|
|
91
|
-
);
|
|
92
|
-
|
|
93
78
|
const Content = ({
|
|
94
79
|
ref,
|
|
95
80
|
className,
|
|
@@ -135,7 +120,7 @@ const Footer = ({
|
|
|
135
120
|
* **When NOT to use:** the left-rail `Sidebar` lives in the app shell outside `PageLayout`. For embedded
|
|
136
121
|
* widgets or modal bodies the layout chrome is the modal / card itself, not `PageLayout`.
|
|
137
122
|
* **Composition:** `PageLayout` is opinionated about chrome only — slots accept any `ReactNode`. Put
|
|
138
|
-
* search / filter pills in `
|
|
123
|
+
* search / filter pills in `PageToolbar`, never in the header. Form submit bars go in
|
|
139
124
|
* `PageLayout.Footer sticky`.
|
|
140
125
|
*
|
|
141
126
|
* @see RECIPES.md#list-detail — for the index → detail page pattern
|
|
@@ -147,8 +132,6 @@ export const PageLayout = Object.assign(Root, {
|
|
|
147
132
|
Header,
|
|
148
133
|
/** Escape hatch for routing-based tab rows. Use `Tabs` / `TabsContent` core components inside, never a raw `<button>`. */
|
|
149
134
|
Tabs,
|
|
150
|
-
/** Search / filter pill row. Belongs here, NOT in the header per the page-header patterns. */
|
|
151
|
-
Toolbar,
|
|
152
135
|
/** Main content slot. Lists, cards, tables. */
|
|
153
136
|
Content,
|
|
154
137
|
/** Bottom action bar — form submit + cancel. Pass `sticky` for a footer that pins to the viewport. */
|