@sovereignfs/ui 0.21.3 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Checkbox.module.css +97 -0
- package/dist/DragHandleRow.module.css +51 -0
- package/dist/FormField.module.css +5 -0
- package/dist/Input.module.css +10 -0
- package/dist/Select.module.css +9 -0
- package/dist/Textarea.module.css +39 -0
- package/dist/Toast.module.css +3 -3
- package/dist/Toggle.module.css +1 -1
- package/dist/index.d.ts +58 -5
- package/dist/index.js +218 -96
- package/dist/tokens/semantic.css +6 -0
- package/package.json +1 -1
- package/src/components/Checkbox/Checkbox.module.css +97 -0
- package/src/components/Checkbox/Checkbox.tsx +68 -0
- package/src/components/DragHandleRow/DragHandleRow.module.css +51 -0
- package/src/components/DragHandleRow/DragHandleRow.tsx +61 -0
- package/src/components/FormField/FormField.module.css +5 -0
- package/src/components/FormField/FormField.tsx +36 -11
- package/src/components/FormField/__tests__/FormField.test.tsx +83 -0
- package/src/components/Input/Input.module.css +10 -0
- package/src/components/Select/Select.module.css +9 -0
- package/src/components/Select/Select.tsx +10 -2
- package/src/components/Textarea/Textarea.module.css +39 -0
- package/src/components/Textarea/Textarea.tsx +15 -0
- package/src/components/Textarea/__tests__/Textarea.test.tsx +29 -0
- package/src/components/Toast/Toast.module.css +3 -3
- package/src/components/Toggle/Toggle.module.css +1 -1
- package/src/index.ts +7 -1
- package/src/stories/Checkbox.stories.tsx +45 -0
- package/src/stories/DesignSystemOverview.stories.tsx +64 -6
- package/src/stories/DragHandleRow.stories.tsx +41 -0
- package/src/stories/FormField.stories.tsx +8 -8
- package/src/stories/MobilePatterns.stories.tsx +4 -4
- package/src/stories/Textarea.stories.tsx +51 -0
- package/src/stories/TokenGallery.stories.tsx +2 -0
- package/src/tokens/semantic.css +6 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { InputHTMLAttributes } from 'react';
|
|
4
|
+
import styles from './Checkbox.module.css';
|
|
5
|
+
|
|
6
|
+
export interface CheckboxProps extends Omit<
|
|
7
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
8
|
+
'type' | 'onChange'
|
|
9
|
+
> {
|
|
10
|
+
checked: boolean;
|
|
11
|
+
onChange: (checked: boolean) => void;
|
|
12
|
+
label: string;
|
|
13
|
+
/** Renders the label with a strike-through when checked. */
|
|
14
|
+
strikeThrough?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Checkbox({
|
|
19
|
+
checked,
|
|
20
|
+
onChange,
|
|
21
|
+
label,
|
|
22
|
+
strikeThrough = false,
|
|
23
|
+
disabled,
|
|
24
|
+
className,
|
|
25
|
+
id,
|
|
26
|
+
...rest
|
|
27
|
+
}: CheckboxProps) {
|
|
28
|
+
const inputId = id ?? `checkbox-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<span className={[styles.root, className].filter(Boolean).join(' ')}>
|
|
32
|
+
<span className={[styles.box, checked ? styles.checked : ''].filter(Boolean).join(' ')}>
|
|
33
|
+
<input
|
|
34
|
+
{...rest}
|
|
35
|
+
id={inputId}
|
|
36
|
+
type="checkbox"
|
|
37
|
+
checked={checked}
|
|
38
|
+
disabled={disabled}
|
|
39
|
+
className={styles.input}
|
|
40
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
41
|
+
/>
|
|
42
|
+
{checked && (
|
|
43
|
+
<svg className={styles.tick} viewBox="0 0 10 8" fill="none" aria-hidden="true">
|
|
44
|
+
<path
|
|
45
|
+
d="M1 4l3 3 5-6"
|
|
46
|
+
stroke="currentColor"
|
|
47
|
+
strokeWidth="1.5"
|
|
48
|
+
strokeLinecap="round"
|
|
49
|
+
strokeLinejoin="round"
|
|
50
|
+
/>
|
|
51
|
+
</svg>
|
|
52
|
+
)}
|
|
53
|
+
</span>
|
|
54
|
+
<label
|
|
55
|
+
htmlFor={inputId}
|
|
56
|
+
className={[
|
|
57
|
+
styles.label,
|
|
58
|
+
strikeThrough && checked ? styles.struck : '',
|
|
59
|
+
disabled ? styles.disabled : '',
|
|
60
|
+
]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(' ')}
|
|
63
|
+
>
|
|
64
|
+
{label}
|
|
65
|
+
</label>
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.row {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: flex-start;
|
|
4
|
+
gap: var(--sv-space-1);
|
|
5
|
+
position: relative;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.row:not(:hover) .handle,
|
|
9
|
+
.row:not(:focus-within) .handle {
|
|
10
|
+
opacity: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.handle {
|
|
14
|
+
flex-shrink: 0;
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
width: 24px;
|
|
19
|
+
height: 24px;
|
|
20
|
+
margin-top: 2px;
|
|
21
|
+
border: none;
|
|
22
|
+
background: none;
|
|
23
|
+
padding: 0;
|
|
24
|
+
border-radius: var(--sv-radius-sm);
|
|
25
|
+
color: var(--sv-color-text-muted);
|
|
26
|
+
cursor: grab;
|
|
27
|
+
opacity: 1;
|
|
28
|
+
transition: opacity 0.1s ease;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.handle:focus-visible {
|
|
32
|
+
outline: 2px solid var(--sv-color-focus-ring);
|
|
33
|
+
outline-offset: 2px;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.handle:active {
|
|
37
|
+
cursor: grabbing;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.icon {
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.content {
|
|
45
|
+
flex: 1;
|
|
46
|
+
min-width: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.dragging {
|
|
50
|
+
opacity: 0.5;
|
|
51
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import styles from './DragHandleRow.module.css';
|
|
3
|
+
|
|
4
|
+
export interface DragHandleRowProps extends HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Pass drag-listener and drag-control props from dnd-kit here. */
|
|
7
|
+
handleProps?: HTMLAttributes<HTMLButtonElement>;
|
|
8
|
+
isDragging?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A row wrapper that surfaces a drag handle on the left.
|
|
13
|
+
* Consumers attach dnd-kit's useSortable listeners to `handleProps`.
|
|
14
|
+
*/
|
|
15
|
+
export function DragHandleRow({
|
|
16
|
+
children,
|
|
17
|
+
handleProps,
|
|
18
|
+
isDragging,
|
|
19
|
+
className,
|
|
20
|
+
...rest
|
|
21
|
+
}: DragHandleRowProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
{...rest}
|
|
25
|
+
className={[styles.row, isDragging ? styles.dragging : '', className]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(' ')}
|
|
28
|
+
>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
aria-label="Drag to reorder"
|
|
32
|
+
className={styles.handle}
|
|
33
|
+
tabIndex={-1}
|
|
34
|
+
{...handleProps}
|
|
35
|
+
>
|
|
36
|
+
<DragIcon />
|
|
37
|
+
</button>
|
|
38
|
+
<div className={styles.content}>{children}</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function DragIcon() {
|
|
44
|
+
return (
|
|
45
|
+
<svg
|
|
46
|
+
width="14"
|
|
47
|
+
height="14"
|
|
48
|
+
viewBox="0 0 14 14"
|
|
49
|
+
fill="none"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
className={styles.icon}
|
|
52
|
+
>
|
|
53
|
+
{/* Two columns of three dots */}
|
|
54
|
+
{[3, 7, 11].map((cy) =>
|
|
55
|
+
[4, 10].map((cx) => (
|
|
56
|
+
<circle key={`${cx}-${cy}`} cx={cx} cy={cy} r={1.2} fill="currentColor" />
|
|
57
|
+
)),
|
|
58
|
+
)}
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
display: flex;
|
|
3
3
|
flex-direction: column;
|
|
4
4
|
gap: var(--sv-space-1);
|
|
5
|
+
/* Every local .field/.fieldGroup class this component replaced set this
|
|
6
|
+
explicitly. Without it, a parent with align-items: flex-start (needed so
|
|
7
|
+
submit buttons don't stretch full-width) shrink-wraps this wrapper to its
|
|
8
|
+
content's intrinsic width instead of filling the available space. */
|
|
9
|
+
width: 100%;
|
|
5
10
|
}
|
|
6
11
|
|
|
7
12
|
.label {
|
|
@@ -2,32 +2,59 @@ import type { ReactNode } from 'react';
|
|
|
2
2
|
import { useId } from 'react';
|
|
3
3
|
import styles from './FormField.module.css';
|
|
4
4
|
|
|
5
|
+
/** Props to spread onto the field's actual form control. */
|
|
6
|
+
export interface FormFieldRenderProps {
|
|
7
|
+
id: string;
|
|
8
|
+
'aria-describedby'?: string;
|
|
9
|
+
'aria-invalid'?: boolean;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
export interface FormFieldProps {
|
|
6
14
|
label: string;
|
|
7
15
|
hint?: string;
|
|
8
16
|
error?: string;
|
|
9
|
-
|
|
17
|
+
/** Explicit control id. Auto-generated via `useId()` when omitted. */
|
|
18
|
+
id?: string;
|
|
10
19
|
required?: boolean;
|
|
11
|
-
children: ReactNode;
|
|
12
20
|
className?: string;
|
|
21
|
+
/** Render prop — receives the props that must be spread onto the control
|
|
22
|
+
* so the label, hint, and error stay associated with it. */
|
|
23
|
+
children: (field: FormFieldRenderProps) => ReactNode;
|
|
13
24
|
}
|
|
14
25
|
|
|
26
|
+
/**
|
|
27
|
+
* FormField — accessible label + hint/error wrapper for a single form control.
|
|
28
|
+
* Wires `htmlFor`/`id` and `aria-describedby`/`aria-invalid` onto the control
|
|
29
|
+
* itself (via the render-prop `field` object), not a surrounding element, so
|
|
30
|
+
* screen readers reliably announce the hint or error as part of the control.
|
|
31
|
+
*/
|
|
15
32
|
export function FormField({
|
|
16
33
|
label,
|
|
17
34
|
hint,
|
|
18
35
|
error,
|
|
19
|
-
|
|
36
|
+
id,
|
|
20
37
|
required = false,
|
|
21
|
-
children,
|
|
22
38
|
className,
|
|
39
|
+
children,
|
|
23
40
|
}: FormFieldProps) {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
41
|
+
const generatedId = useId();
|
|
42
|
+
const fieldId = id ?? generatedId;
|
|
43
|
+
const hintId = `${fieldId}-hint`;
|
|
44
|
+
const errorId = `${fieldId}-error`;
|
|
45
|
+
const describedBy =
|
|
46
|
+
[hint && !error && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
|
|
47
|
+
|
|
48
|
+
const field: FormFieldRenderProps = {
|
|
49
|
+
id: fieldId,
|
|
50
|
+
'aria-describedby': describedBy,
|
|
51
|
+
'aria-invalid': error ? true : undefined,
|
|
52
|
+
required: required || undefined,
|
|
53
|
+
};
|
|
27
54
|
|
|
28
55
|
return (
|
|
29
56
|
<div className={[styles.field, className].filter(Boolean).join(' ')}>
|
|
30
|
-
<label className={styles.label} htmlFor={
|
|
57
|
+
<label className={styles.label} htmlFor={fieldId}>
|
|
31
58
|
{label}
|
|
32
59
|
{required && (
|
|
33
60
|
<span className={styles.required} aria-hidden="true">
|
|
@@ -35,9 +62,7 @@ export function FormField({
|
|
|
35
62
|
</span>
|
|
36
63
|
)}
|
|
37
64
|
</label>
|
|
38
|
-
{
|
|
39
|
-
and let the consumer wire htmlFor/id themselves */}
|
|
40
|
-
<div aria-describedby={describedBy}>{children}</div>
|
|
65
|
+
{children(field)}
|
|
41
66
|
{hint && !error && (
|
|
42
67
|
<p id={hintId} className={styles.hint}>
|
|
43
68
|
{hint}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { FormField } from '../FormField';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('FormField', () => {
|
|
9
|
+
it('associates the label with the control via htmlFor/id', () => {
|
|
10
|
+
render(
|
|
11
|
+
<FormField label="Email" id="email">
|
|
12
|
+
{(field) => <input {...field} />}
|
|
13
|
+
</FormField>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByLabelText('Email')).toBeDefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('generates an id when none is provided', () => {
|
|
19
|
+
render(<FormField label="Email">{(field) => <input {...field} />}</FormField>);
|
|
20
|
+
const input = screen.getByLabelText('Email') as HTMLInputElement;
|
|
21
|
+
expect(input.id).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('wires the hint to the control via aria-describedby', () => {
|
|
25
|
+
render(
|
|
26
|
+
<FormField label="Username" hint="Letters and numbers only." id="username">
|
|
27
|
+
{(field) => <input {...field} />}
|
|
28
|
+
</FormField>,
|
|
29
|
+
);
|
|
30
|
+
const input = screen.getByLabelText('Username');
|
|
31
|
+
const hint = screen.getByText('Letters and numbers only.');
|
|
32
|
+
expect(input.getAttribute('aria-describedby')).toBe(hint.id);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('wires the error to the control via aria-describedby and marks it invalid', () => {
|
|
36
|
+
render(
|
|
37
|
+
<FormField label="Password" error="Too short." id="password">
|
|
38
|
+
{(field) => <input {...field} />}
|
|
39
|
+
</FormField>,
|
|
40
|
+
);
|
|
41
|
+
const input = screen.getByLabelText('Password');
|
|
42
|
+
const error = screen.getByText('Too short.');
|
|
43
|
+
expect(input.getAttribute('aria-describedby')).toBe(error.id);
|
|
44
|
+
expect(input.getAttribute('aria-invalid')).toBe('true');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders the error with role="alert"', () => {
|
|
48
|
+
render(
|
|
49
|
+
<FormField label="Password" error="Too short." id="password">
|
|
50
|
+
{(field) => <input {...field} />}
|
|
51
|
+
</FormField>,
|
|
52
|
+
);
|
|
53
|
+
expect(screen.getByRole('alert').textContent).toBe('Too short.');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('hides the hint when an error is present', () => {
|
|
57
|
+
render(
|
|
58
|
+
<FormField label="Password" hint="At least 8 characters." error="Too short." id="password">
|
|
59
|
+
{(field) => <input {...field} />}
|
|
60
|
+
</FormField>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.queryByText('At least 8 characters.')).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('shows a required indicator and sets required on the control', () => {
|
|
66
|
+
render(
|
|
67
|
+
<FormField label="Full name" required id="name">
|
|
68
|
+
{(field) => <input {...field} />}
|
|
69
|
+
</FormField>,
|
|
70
|
+
);
|
|
71
|
+
const input = screen.getByLabelText(/Full name/) as HTMLInputElement;
|
|
72
|
+
expect(input.required).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not set aria-describedby when there is no hint or error', () => {
|
|
76
|
+
render(
|
|
77
|
+
<FormField label="Nickname" id="nickname">
|
|
78
|
+
{(field) => <input {...field} />}
|
|
79
|
+
</FormField>,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByLabelText('Nickname').hasAttribute('aria-describedby')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
box-shadow 0.15s ease;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/* iOS Safari (tab and standalone PWA) auto-zooms the page whenever a focused
|
|
18
|
+
input's font-size is below 16px. The viewport intentionally allows pinch-zoom
|
|
19
|
+
(no maximum-scale — an accessibility requirement), so the only way to stop the
|
|
20
|
+
focus-zoom is to render the field at >=16px on touch devices. */
|
|
21
|
+
@media (pointer: coarse) {
|
|
22
|
+
.input {
|
|
23
|
+
font-size: var(--sv-font-size-md);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
.input::placeholder {
|
|
18
28
|
color: var(--sv-color-text-muted);
|
|
19
29
|
}
|
|
@@ -33,6 +33,15 @@
|
|
|
33
33
|
border-radius: var(--sv-radius-sm);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/* iOS Safari zooms the page when a <select> under 16px receives focus, exactly
|
|
37
|
+
as it does for text inputs. Lift the default size to >=16px on touch devices;
|
|
38
|
+
the compact `.sm` variant is for dense desktop tables and is left untouched. */
|
|
39
|
+
@media (pointer: coarse) {
|
|
40
|
+
.select {
|
|
41
|
+
font-size: var(--sv-font-size-md);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
.select:focus-visible {
|
|
37
46
|
outline: none;
|
|
38
47
|
border-color: var(--sv-color-accent);
|
|
@@ -12,13 +12,21 @@ export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'>
|
|
|
12
12
|
* Uses the native picker on mobile — no custom dropdown, maximum a11y coverage.
|
|
13
13
|
*
|
|
14
14
|
* RSC-safe: presentational, no hooks, forwards all native select props.
|
|
15
|
+
*
|
|
16
|
+
* `className` sizes the outer box (the wrapper), not the `<select>` itself —
|
|
17
|
+
* the chevron is absolutely positioned against the wrapper, so a className
|
|
18
|
+
* that constrains only the `<select>` (e.g. `max-width`) shrinks the select
|
|
19
|
+
* while the wrapper (and thus the chevron) stays full width, leaving the
|
|
20
|
+
* chevron stranded past the visible box.
|
|
15
21
|
*/
|
|
16
22
|
export function Select({ className, size = 'md', children, ...rest }: SelectProps) {
|
|
17
23
|
return (
|
|
18
24
|
<div
|
|
19
|
-
className={[styles.wrapper, size === 'sm' ? styles.sm : undefined]
|
|
25
|
+
className={[styles.wrapper, size === 'sm' ? styles.sm : undefined, className]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join(' ')}
|
|
20
28
|
>
|
|
21
|
-
<select className={
|
|
29
|
+
<select className={styles.select} {...rest}>
|
|
22
30
|
{children}
|
|
23
31
|
</select>
|
|
24
32
|
<svg
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
.textarea {
|
|
2
|
+
display: block;
|
|
3
|
+
width: 100%;
|
|
4
|
+
padding: var(--sv-space-2) var(--sv-space-3);
|
|
5
|
+
font-family: var(--sv-font-family);
|
|
6
|
+
font-size: var(--sv-font-size-sm);
|
|
7
|
+
line-height: 1.5;
|
|
8
|
+
color: var(--sv-color-text-primary);
|
|
9
|
+
background-color: var(--sv-color-surface);
|
|
10
|
+
border: 1px solid var(--sv-color-border-strong);
|
|
11
|
+
border-radius: var(--sv-radius-md);
|
|
12
|
+
resize: vertical;
|
|
13
|
+
transition:
|
|
14
|
+
border-color 0.15s ease,
|
|
15
|
+
box-shadow 0.15s ease;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.textarea::placeholder {
|
|
19
|
+
color: var(--sv-color-text-muted);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.textarea:focus-visible {
|
|
23
|
+
outline: none;
|
|
24
|
+
border-color: var(--sv-color-accent);
|
|
25
|
+
box-shadow: 0 0 0 2px var(--sv-color-focus-ring);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.textarea:read-only {
|
|
29
|
+
background-color: var(--sv-color-surface-sunken);
|
|
30
|
+
color: var(--sv-color-text-muted);
|
|
31
|
+
border-color: var(--sv-color-border);
|
|
32
|
+
cursor: default;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.textarea:disabled {
|
|
36
|
+
opacity: 0.5;
|
|
37
|
+
cursor: not-allowed;
|
|
38
|
+
resize: none;
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TextareaHTMLAttributes } from 'react';
|
|
2
|
+
import styles from './Textarea.module.css';
|
|
3
|
+
|
|
4
|
+
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Textarea — the primitive multi-line text field. Presentational and
|
|
8
|
+
* RSC-safe: it forwards all native textarea props to the underlying
|
|
9
|
+
* `<textarea>`. Styling references `--sv-*` tokens via CSS Modules; there
|
|
10
|
+
* are no hardcoded values.
|
|
11
|
+
*/
|
|
12
|
+
export function Textarea({ className, rows = 4, ...rest }: TextareaProps) {
|
|
13
|
+
const classes = [styles.textarea, className].filter(Boolean).join(' ');
|
|
14
|
+
return <textarea rows={rows} className={classes} {...rest} />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
4
|
+
import { Textarea } from '../Textarea';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('Textarea', () => {
|
|
9
|
+
it('renders and forwards native props', () => {
|
|
10
|
+
render(<Textarea placeholder="Bio" defaultValue="Hello" />);
|
|
11
|
+
const textarea = screen.getByPlaceholderText('Bio') as HTMLTextAreaElement;
|
|
12
|
+
expect(textarea.value).toBe('Hello');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('defaults to 4 rows', () => {
|
|
16
|
+
render(<Textarea aria-label="field" />);
|
|
17
|
+
expect((screen.getByLabelText('field') as HTMLTextAreaElement).rows).toBe(4);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('honours an explicit rows value', () => {
|
|
21
|
+
render(<Textarea aria-label="field" rows={8} />);
|
|
22
|
+
expect((screen.getByLabelText('field') as HTMLTextAreaElement).rows).toBe(8);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('is disabled when the disabled prop is set', () => {
|
|
26
|
+
render(<Textarea aria-label="field" disabled />);
|
|
27
|
+
expect((screen.getByLabelText('field') as HTMLTextAreaElement).disabled).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
|
|
81
81
|
.message {
|
|
82
82
|
font-size: var(--sv-font-size-sm);
|
|
83
|
-
color: var(--sv-color-text-
|
|
83
|
+
color: var(--sv-color-text-muted);
|
|
84
84
|
margin-top: var(--sv-space-1);
|
|
85
85
|
line-height: 1.4;
|
|
86
86
|
}
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
border: none;
|
|
92
92
|
padding: 0;
|
|
93
93
|
cursor: pointer;
|
|
94
|
-
color: var(--sv-color-text-
|
|
94
|
+
color: var(--sv-color-text-subtle);
|
|
95
95
|
line-height: 1;
|
|
96
|
-
font-size: var(--sv-font-size-
|
|
96
|
+
font-size: var(--sv-font-size-md);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
.close:hover {
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export { Button } from './components/Button/Button';
|
|
|
21
21
|
export type { ButtonProps, ButtonVariant, ButtonSize } from './components/Button/Button';
|
|
22
22
|
export { Input } from './components/Input/Input';
|
|
23
23
|
export type { InputProps } from './components/Input/Input';
|
|
24
|
+
export { Textarea } from './components/Textarea/Textarea';
|
|
25
|
+
export type { TextareaProps } from './components/Textarea/Textarea';
|
|
24
26
|
export { Dialog } from './components/Dialog/Dialog';
|
|
25
27
|
export type { DialogProps, DialogSize } from './components/Dialog/Dialog';
|
|
26
28
|
export { Drawer } from './components/Drawer/Drawer';
|
|
@@ -30,7 +32,7 @@ export type { ToastItem, ToastContextValue } from './components/Toast/Toast';
|
|
|
30
32
|
export { Card } from './components/Card/Card';
|
|
31
33
|
export type { CardProps } from './components/Card/Card';
|
|
32
34
|
export { FormField } from './components/FormField/FormField';
|
|
33
|
-
export type { FormFieldProps } from './components/FormField/FormField';
|
|
35
|
+
export type { FormFieldProps, FormFieldRenderProps } from './components/FormField/FormField';
|
|
34
36
|
export { PageHeader } from './components/PageHeader/PageHeader';
|
|
35
37
|
export type { PageHeaderProps } from './components/PageHeader/PageHeader';
|
|
36
38
|
export { EmptyState } from './components/EmptyState/EmptyState';
|
|
@@ -43,3 +45,7 @@ export { NavTabs } from './components/NavTabs/NavTabs';
|
|
|
43
45
|
export type { NavTabsProps, NavTabItem } from './components/NavTabs/NavTabs';
|
|
44
46
|
export { Tooltip } from './components/Tooltip/Tooltip';
|
|
45
47
|
export type { TooltipProps } from './components/Tooltip/Tooltip';
|
|
48
|
+
export { Checkbox } from './components/Checkbox/Checkbox';
|
|
49
|
+
export type { CheckboxProps } from './components/Checkbox/Checkbox';
|
|
50
|
+
export { DragHandleRow } from './components/DragHandleRow/DragHandleRow';
|
|
51
|
+
export type { DragHandleRowProps } from './components/DragHandleRow/DragHandleRow';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Checkbox } from '../components/Checkbox/Checkbox';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Checkbox> = {
|
|
6
|
+
title: 'Components/Checkbox',
|
|
7
|
+
component: Checkbox,
|
|
8
|
+
parameters: { layout: 'centered' },
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default meta;
|
|
13
|
+
type Story = StoryObj<typeof Checkbox>;
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => {
|
|
17
|
+
const [checked, setChecked] = useState(false);
|
|
18
|
+
return <Checkbox checked={checked} onChange={setChecked} label="Task title" />;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const Checked: Story = {
|
|
23
|
+
render: () => {
|
|
24
|
+
const [checked, setChecked] = useState(true);
|
|
25
|
+
return <Checkbox checked={checked} onChange={setChecked} label="Already done" />;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const WithStrikeThrough: Story = {
|
|
30
|
+
name: 'Strike-through on complete',
|
|
31
|
+
render: () => {
|
|
32
|
+
const [checked, setChecked] = useState(false);
|
|
33
|
+
return (
|
|
34
|
+
<Checkbox checked={checked} onChange={setChecked} label="Click to complete" strikeThrough />
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const Disabled: Story = {
|
|
40
|
+
render: () => <Checkbox checked={false} onChange={() => {}} label="Cannot interact" disabled />,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const DisabledChecked: Story = {
|
|
44
|
+
render: () => <Checkbox checked onChange={() => {}} label="Locked complete" disabled />,
|
|
45
|
+
};
|