@sovereignfs/ui 0.37.0 → 0.41.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/BalanceChip.module.css +32 -0
- package/dist/FileDropzone.module.css +63 -0
- package/dist/MemberMultiSelect.module.css +27 -0
- package/dist/index.d.ts +151 -3
- package/dist/index.js +630 -370
- package/package.json +1 -1
- package/src/components/BalanceChip/BalanceChip.module.css +32 -0
- package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
- package/src/components/BalanceChip/BalanceChip.tsx +33 -0
- package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
- package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
- package/src/components/FileDropzone/FileDropzone.module.css +63 -0
- package/src/components/FileDropzone/FileDropzone.tsx +109 -0
- package/src/components/FileDropzone/__tests__/FileDropzone.test.tsx +67 -0
- package/src/components/Icon/icons/external-link.tsx +22 -0
- package/src/components/Icon/icons/file.tsx +21 -0
- package/src/components/Icon/icons/index.ts +12 -0
- package/src/components/Icon/icons/upload.tsx +22 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
- package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
- package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useCommitOnEnterOrBlur.ts +39 -0
- package/src/index.ts +24 -2
- package/src/stories/DesignSystemOverview.stories.tsx +119 -1
- package/src/stories/FileDropzone.stories.tsx +69 -0
- package/src/stories/InteractionHooks.stories.tsx +55 -1
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.chip {
|
|
2
|
+
display: inline-flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
max-width: 100%;
|
|
5
|
+
min-height: 24px;
|
|
6
|
+
padding: 2px var(--sv-space-2);
|
|
7
|
+
border: 1px solid var(--sv-color-border);
|
|
8
|
+
border-radius: var(--sv-radius-full);
|
|
9
|
+
font-family: var(--sv-font-family);
|
|
10
|
+
font-size: var(--sv-font-size-xs);
|
|
11
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
12
|
+
line-height: 1.2;
|
|
13
|
+
white-space: nowrap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.owed {
|
|
17
|
+
color: var(--sv-color-success-text);
|
|
18
|
+
background: var(--sv-color-success-surface);
|
|
19
|
+
border-color: var(--sv-color-success-border);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.owes {
|
|
23
|
+
color: var(--sv-color-error-text);
|
|
24
|
+
background: var(--sv-color-error-surface);
|
|
25
|
+
border-color: var(--sv-color-error-border);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.settled {
|
|
29
|
+
color: var(--sv-color-text-muted);
|
|
30
|
+
background: var(--sv-color-surface-sunken);
|
|
31
|
+
border-color: var(--sv-color-border);
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { BalanceChip } from './BalanceChip';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/BalanceChip',
|
|
6
|
+
component: BalanceChip,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
'Inline net-balance indicator — green when owed to them, red when they owe, neutral when settled. Not tied to expense-splitting specifically: any plugin tracking a signed balance between people can use it.',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
args: {
|
|
17
|
+
amountCents: 2500,
|
|
18
|
+
currency: 'USD',
|
|
19
|
+
},
|
|
20
|
+
} satisfies Meta<typeof BalanceChip>;
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
|
|
25
|
+
export const Owed: Story = {
|
|
26
|
+
args: { amountCents: 2500, currency: 'USD' },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const Owes: Story = {
|
|
30
|
+
args: { amountCents: -1350, currency: 'USD' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const SettledUp: Story = {
|
|
34
|
+
args: { amountCents: 0, currency: 'USD' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const AllStates: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<div style={{ display: 'flex', gap: 'var(--sv-space-2)' }}>
|
|
40
|
+
<BalanceChip amountCents={2500} currency="USD" />
|
|
41
|
+
<BalanceChip amountCents={-1350} currency="USD" />
|
|
42
|
+
<BalanceChip amountCents={0} currency="USD" />
|
|
43
|
+
</div>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import styles from './BalanceChip.module.css';
|
|
2
|
+
|
|
3
|
+
export interface BalanceChipProps {
|
|
4
|
+
/** Smallest currency unit (cents). Positive = owed to them, negative = they owe, zero = settled. */
|
|
5
|
+
amountCents: number;
|
|
6
|
+
/** ISO 4217 currency code, e.g. "USD". */
|
|
7
|
+
currency: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatAbsCents(cents: number): string {
|
|
12
|
+
return (Math.abs(cents) / 100).toFixed(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* BalanceChip — inline balance indicator (green = owed to them, red = they
|
|
17
|
+
* owe, neutral = settled up). For any plugin tracking who-owes-whom — not
|
|
18
|
+
* Tally-specific despite the naming; "amountCents" is a generic signed net
|
|
19
|
+
* balance, not tied to expense-splitting semantics.
|
|
20
|
+
*/
|
|
21
|
+
export function BalanceChip({ amountCents, currency, className }: BalanceChipProps) {
|
|
22
|
+
const direction = amountCents > 0 ? 'owed' : amountCents < 0 ? 'owes' : 'settled';
|
|
23
|
+
const text =
|
|
24
|
+
direction === 'settled'
|
|
25
|
+
? 'Settled up'
|
|
26
|
+
: `${direction === 'owed' ? 'Owed' : 'Owes'} ${currency} ${formatAbsCents(amountCents)}`;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<span className={[styles.chip, styles[direction], className].filter(Boolean).join(' ')}>
|
|
30
|
+
{text}
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { CurrencyInput } from './CurrencyInput';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Components/CurrencyInput',
|
|
7
|
+
component: CurrencyInput,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'padded',
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Decimal amount entry that reports its value as integer cents, matching the "amounts are always smallest-unit integers" data-model convention. Preserves in-progress typing (e.g. a trailing decimal point) instead of reformatting on every keystroke.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
valueCents: null,
|
|
19
|
+
onValueChange: () => {},
|
|
20
|
+
},
|
|
21
|
+
} satisfies Meta<typeof CurrencyInput>;
|
|
22
|
+
|
|
23
|
+
export default meta;
|
|
24
|
+
type Story = StoryObj<typeof meta>;
|
|
25
|
+
|
|
26
|
+
function ControlledCurrencyInput({ initialCents }: { initialCents: number | null }) {
|
|
27
|
+
const [cents, setCents] = useState<number | null>(initialCents);
|
|
28
|
+
return (
|
|
29
|
+
<CurrencyInput
|
|
30
|
+
valueCents={cents}
|
|
31
|
+
onValueChange={setCents}
|
|
32
|
+
placeholder="0.00"
|
|
33
|
+
aria-label="Amount"
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const Empty: Story = {
|
|
39
|
+
render: () => <ControlledCurrencyInput initialCents={null} />,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const Prefilled: Story = {
|
|
43
|
+
render: () => <ControlledCurrencyInput initialCents={4250} />,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Disabled: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<CurrencyInput valueCents={1000} onValueChange={() => {}} aria-label="Amount" disabled />
|
|
49
|
+
),
|
|
50
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Input, type InputProps } from '../Input/Input';
|
|
5
|
+
|
|
6
|
+
export interface CurrencyInputProps extends Omit<
|
|
7
|
+
InputProps,
|
|
8
|
+
'value' | 'onChange' | 'type' | 'inputMode'
|
|
9
|
+
> {
|
|
10
|
+
/** Smallest currency unit (cents) — never a float. Null when the field is empty or unparsable. */
|
|
11
|
+
valueCents: number | null;
|
|
12
|
+
onValueChange: (cents: number | null) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function centsToText(cents: number): string {
|
|
16
|
+
return (cents / 100).toFixed(2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function textToCents(text: string): number | null {
|
|
20
|
+
const trimmed = text.trim();
|
|
21
|
+
if (!trimmed) return null;
|
|
22
|
+
const parsed = Number(trimmed);
|
|
23
|
+
if (!Number.isFinite(parsed)) return null;
|
|
24
|
+
return Math.round(parsed * 100);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CurrencyInput — decimal amount entry that reports its value as integer
|
|
29
|
+
* cents, matching Sovereign's "amounts are always smallest-unit integers"
|
|
30
|
+
* data-model convention (never a float in application state).
|
|
31
|
+
*
|
|
32
|
+
* Keeps its own text buffer rather than deriving display text from
|
|
33
|
+
* `valueCents` on every keystroke — reformatting mid-type (e.g. "12." → the
|
|
34
|
+
* trailing decimal point) would fight the user's cursor. It only
|
|
35
|
+
* re-syncs from `valueCents` when the prop changes to something the current
|
|
36
|
+
* text doesn't already represent (an external reset/prefill), so typing
|
|
37
|
+
* remains uninterrupted while the parsed amount stays in lockstep with the
|
|
38
|
+
* parent's state.
|
|
39
|
+
*/
|
|
40
|
+
export function CurrencyInput({ valueCents, onValueChange, ...rest }: CurrencyInputProps) {
|
|
41
|
+
const [text, setText] = useState(() => (valueCents != null ? centsToText(valueCents) : ''));
|
|
42
|
+
|
|
43
|
+
if (textToCents(text) !== valueCents) {
|
|
44
|
+
setText(valueCents != null ? centsToText(valueCents) : '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Input
|
|
49
|
+
{...rest}
|
|
50
|
+
type="text"
|
|
51
|
+
inputMode="decimal"
|
|
52
|
+
value={text}
|
|
53
|
+
onChange={(e) => {
|
|
54
|
+
const next = e.currentTarget.value;
|
|
55
|
+
setText(next);
|
|
56
|
+
onValueChange(textToCents(next));
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
.dropzone {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--sv-space-4);
|
|
5
|
+
width: 100%;
|
|
6
|
+
box-sizing: border-box;
|
|
7
|
+
padding: var(--sv-space-4) var(--sv-space-5);
|
|
8
|
+
border: 1.5px dashed var(--sv-color-border);
|
|
9
|
+
border-radius: var(--sv-radius-md);
|
|
10
|
+
background-color: var(--sv-color-surface-raised);
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
transition:
|
|
13
|
+
background-color 0.12s ease,
|
|
14
|
+
border-color 0.12s ease;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@media (hover: hover) {
|
|
18
|
+
.dropzone:hover {
|
|
19
|
+
background-color: var(--sv-color-surface-sunken);
|
|
20
|
+
border-color: var(--sv-color-border-strong);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.dropzoneActive {
|
|
25
|
+
background-color: var(--sv-color-surface-sunken);
|
|
26
|
+
border-color: var(--sv-color-border-strong);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.dropzoneDisabled {
|
|
30
|
+
opacity: 0.5;
|
|
31
|
+
cursor: not-allowed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.icon {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
flex-shrink: 0;
|
|
37
|
+
color: var(--sv-color-text-muted);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.text {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
gap: 2px;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.title {
|
|
48
|
+
font-size: var(--sv-font-size-sm);
|
|
49
|
+
font-weight: var(--sv-font-weight-medium);
|
|
50
|
+
color: var(--sv-color-text-primary);
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
text-overflow: ellipsis;
|
|
53
|
+
white-space: nowrap;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.hint {
|
|
57
|
+
font-size: var(--sv-font-size-xs);
|
|
58
|
+
color: var(--sv-color-text-muted);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.hiddenInput {
|
|
62
|
+
display: none;
|
|
63
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { DragEvent, ReactNode } from 'react';
|
|
4
|
+
import { useRef, useState } from 'react';
|
|
5
|
+
import { Icon } from '../Icon/Icon';
|
|
6
|
+
import styles from './FileDropzone.module.css';
|
|
7
|
+
|
|
8
|
+
export interface FileDropzoneProps {
|
|
9
|
+
/** Native `<input type="file">` `name` attribute — set this to submit the
|
|
10
|
+
* file as part of a native `<form action={...}>` (Server Action) request. */
|
|
11
|
+
name?: string;
|
|
12
|
+
/** Native `<input type="file">` `accept` attribute, e.g. `".zip,application/zip"`. */
|
|
13
|
+
accept?: string;
|
|
14
|
+
/** Icon shown beside the label — a file-type glyph. Defaults to a generic file icon. */
|
|
15
|
+
icon?: ReactNode;
|
|
16
|
+
/** Primary label — e.g. "Choose a ZIP file", or the selected file's name. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Secondary hint — e.g. "or drag and drop here", or the selected file's size. */
|
|
19
|
+
hint?: string;
|
|
20
|
+
/** Called with the picked or dropped file, or `null` if the input is cleared. */
|
|
21
|
+
onFileSelect: (file: File | null) => void;
|
|
22
|
+
/** Accessible name for the control — describe what will be uploaded. */
|
|
23
|
+
ariaLabel: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A styled drag-and-drop file picker: a dashed-border dropzone wrapping a
|
|
30
|
+
* visually-hidden native `<input type="file">`, so the native picker still
|
|
31
|
+
* opens on click and the field participates in normal form semantics. A
|
|
32
|
+
* dropped file is written into the input's own `FileList` (via `DataTransfer`)
|
|
33
|
+
* so `name` + native `<form action>` submission sees it exactly like a
|
|
34
|
+
* click-picked file — not just the `onFileSelect` callback.
|
|
35
|
+
*
|
|
36
|
+
* Caller owns the selected-file state and passes `label`/`hint` accordingly
|
|
37
|
+
* (e.g. swap to the picked file's name/size) — this component is otherwise
|
|
38
|
+
* uncontrolled and stateless beyond its own drag-hover visual.
|
|
39
|
+
*
|
|
40
|
+
* Do not pass `required` through to a wrapping form's validation via this
|
|
41
|
+
* field — `display: none` on the hidden input does not exempt it from HTML5
|
|
42
|
+
* constraint validation, so a `required` hidden input can block submission
|
|
43
|
+
* with no visible field for the user to focus. Validate `onFileSelect`'s
|
|
44
|
+
* result in application code instead.
|
|
45
|
+
*/
|
|
46
|
+
export function FileDropzone({
|
|
47
|
+
name,
|
|
48
|
+
accept,
|
|
49
|
+
icon,
|
|
50
|
+
label,
|
|
51
|
+
hint,
|
|
52
|
+
onFileSelect,
|
|
53
|
+
ariaLabel,
|
|
54
|
+
disabled,
|
|
55
|
+
className,
|
|
56
|
+
}: FileDropzoneProps) {
|
|
57
|
+
const [dragging, setDragging] = useState(false);
|
|
58
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<label
|
|
62
|
+
className={[
|
|
63
|
+
styles.dropzone,
|
|
64
|
+
dragging ? styles.dropzoneActive : '',
|
|
65
|
+
disabled ? styles.dropzoneDisabled : '',
|
|
66
|
+
className,
|
|
67
|
+
]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join(' ')}
|
|
70
|
+
onDragOver={(event: DragEvent) => {
|
|
71
|
+
if (disabled) return;
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
setDragging(true);
|
|
74
|
+
}}
|
|
75
|
+
onDragLeave={() => setDragging(false)}
|
|
76
|
+
onDrop={(event: DragEvent) => {
|
|
77
|
+
if (disabled) return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
setDragging(false);
|
|
80
|
+
const dropped = event.dataTransfer.files[0];
|
|
81
|
+
if (!dropped) return;
|
|
82
|
+
if (inputRef.current && typeof DataTransfer !== 'undefined') {
|
|
83
|
+
const transfer = new DataTransfer();
|
|
84
|
+
transfer.items.add(dropped);
|
|
85
|
+
inputRef.current.files = transfer.files;
|
|
86
|
+
}
|
|
87
|
+
onFileSelect(dropped);
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<span className={styles.icon} aria-hidden="true">
|
|
91
|
+
{icon ?? <Icon name="file" size="lg" aria-hidden />}
|
|
92
|
+
</span>
|
|
93
|
+
<span className={styles.text}>
|
|
94
|
+
<span className={styles.title}>{label}</span>
|
|
95
|
+
{hint && <span className={styles.hint}>{hint}</span>}
|
|
96
|
+
</span>
|
|
97
|
+
<input
|
|
98
|
+
ref={inputRef}
|
|
99
|
+
type="file"
|
|
100
|
+
name={name}
|
|
101
|
+
aria-label={ariaLabel}
|
|
102
|
+
accept={accept}
|
|
103
|
+
disabled={disabled}
|
|
104
|
+
onChange={(event) => onFileSelect(event.target.files?.[0] ?? null)}
|
|
105
|
+
className={styles.hiddenInput}
|
|
106
|
+
/>
|
|
107
|
+
</label>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import { FileDropzone } from '../FileDropzone';
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
describe('FileDropzone', () => {
|
|
9
|
+
it('renders the label and hint', () => {
|
|
10
|
+
render(
|
|
11
|
+
<FileDropzone
|
|
12
|
+
label="Choose a ZIP file"
|
|
13
|
+
hint="or drag and drop here"
|
|
14
|
+
ariaLabel="Upload ZIP file"
|
|
15
|
+
onFileSelect={vi.fn()}
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Choose a ZIP file')).toBeDefined();
|
|
19
|
+
expect(screen.getByText('or drag and drop here')).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('exposes an accessible name via aria-label', () => {
|
|
23
|
+
render(
|
|
24
|
+
<FileDropzone label="Choose a file" ariaLabel="Upload font file" onFileSelect={vi.fn()} />,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByLabelText('Upload font file')).toBeDefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('forwards accept to the native file input', () => {
|
|
30
|
+
render(
|
|
31
|
+
<FileDropzone
|
|
32
|
+
label="Choose a file"
|
|
33
|
+
ariaLabel="Upload"
|
|
34
|
+
accept=".zip"
|
|
35
|
+
onFileSelect={vi.fn()}
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
const input = screen.getByLabelText('Upload') as HTMLInputElement;
|
|
39
|
+
expect(input.accept).toBe('.zip');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('calls onFileSelect with the picked file', () => {
|
|
43
|
+
const onFileSelect = vi.fn();
|
|
44
|
+
render(<FileDropzone label="Choose a file" ariaLabel="Upload" onFileSelect={onFileSelect} />);
|
|
45
|
+
const input = screen.getByLabelText('Upload') as HTMLInputElement;
|
|
46
|
+
const file = new File(['content'], 'font.woff2');
|
|
47
|
+
fireEvent.change(input, { target: { files: [file] } });
|
|
48
|
+
expect(onFileSelect).toHaveBeenCalledWith(file);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('calls onFileSelect with the dropped file', () => {
|
|
52
|
+
const onFileSelect = vi.fn();
|
|
53
|
+
render(<FileDropzone label="Choose a file" ariaLabel="Upload" onFileSelect={onFileSelect} />);
|
|
54
|
+
const dropzone = screen.getByLabelText('Upload').closest('label');
|
|
55
|
+
if (!dropzone) throw new Error('dropzone label not found');
|
|
56
|
+
const file = new File(['content'], 'font.woff2');
|
|
57
|
+
fireEvent.drop(dropzone, { dataTransfer: { files: [file] } });
|
|
58
|
+
expect(onFileSelect).toHaveBeenCalledWith(file);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('is disabled when the disabled prop is set', () => {
|
|
62
|
+
render(
|
|
63
|
+
<FileDropzone label="Choose a file" ariaLabel="Upload" disabled onFileSelect={vi.fn()} />,
|
|
64
|
+
);
|
|
65
|
+
expect((screen.getByLabelText('Upload') as HTMLInputElement).disabled).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Generated by scripts/generate-icons.ts — do not edit.
|
|
2
|
+
// Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
|
|
3
|
+
import type { SVGProps } from 'react';
|
|
4
|
+
|
|
5
|
+
export function ExternalLinkIcon(props: SVGProps<SVGSVGElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="2"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<path d="M15 3h6v6" />
|
|
18
|
+
<path d="M10 14 21 3" />
|
|
19
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Generated by scripts/generate-icons.ts — do not edit.
|
|
2
|
+
// Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
|
|
3
|
+
import type { SVGProps } from 'react';
|
|
4
|
+
|
|
5
|
+
export function FileIcon(props: SVGProps<SVGSVGElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="2"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
|
18
|
+
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -29,6 +29,9 @@ import { AlertTriangleIcon } from './alert-triangle';
|
|
|
29
29
|
import { CalendarIcon } from './calendar';
|
|
30
30
|
import { SlidersHorizontalIcon } from './sliders-horizontal';
|
|
31
31
|
import { EllipsisVerticalIcon } from './ellipsis-vertical';
|
|
32
|
+
import { FileIcon } from './file';
|
|
33
|
+
import { UploadIcon } from './upload';
|
|
34
|
+
import { ExternalLinkIcon } from './external-link';
|
|
32
35
|
import { BananaIcon } from './banana';
|
|
33
36
|
import { AppleIcon } from './apple';
|
|
34
37
|
import { CarrotIcon } from './carrot';
|
|
@@ -80,6 +83,9 @@ export type IconName =
|
|
|
80
83
|
| 'calendar'
|
|
81
84
|
| 'sliders-horizontal'
|
|
82
85
|
| 'ellipsis-vertical'
|
|
86
|
+
| 'file'
|
|
87
|
+
| 'upload'
|
|
88
|
+
| 'external-link'
|
|
83
89
|
| 'banana'
|
|
84
90
|
| 'apple'
|
|
85
91
|
| 'carrot'
|
|
@@ -133,6 +139,9 @@ export const ICONS: Record<IconName, IconComponent> = {
|
|
|
133
139
|
calendar: CalendarIcon,
|
|
134
140
|
'sliders-horizontal': SlidersHorizontalIcon,
|
|
135
141
|
'ellipsis-vertical': EllipsisVerticalIcon,
|
|
142
|
+
file: FileIcon,
|
|
143
|
+
upload: UploadIcon,
|
|
144
|
+
'external-link': ExternalLinkIcon,
|
|
136
145
|
banana: BananaIcon,
|
|
137
146
|
apple: AppleIcon,
|
|
138
147
|
carrot: CarrotIcon,
|
|
@@ -184,6 +193,9 @@ export { AlertTriangleIcon } from './alert-triangle';
|
|
|
184
193
|
export { CalendarIcon } from './calendar';
|
|
185
194
|
export { SlidersHorizontalIcon } from './sliders-horizontal';
|
|
186
195
|
export { EllipsisVerticalIcon } from './ellipsis-vertical';
|
|
196
|
+
export { FileIcon } from './file';
|
|
197
|
+
export { UploadIcon } from './upload';
|
|
198
|
+
export { ExternalLinkIcon } from './external-link';
|
|
187
199
|
export { BananaIcon } from './banana';
|
|
188
200
|
export { AppleIcon } from './apple';
|
|
189
201
|
export { CarrotIcon } from './carrot';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Generated by scripts/generate-icons.ts — do not edit.
|
|
2
|
+
// Source: Lucide (https://lucide.dev) — ISC license, see packages/ui/NOTICE.
|
|
3
|
+
import type { SVGProps } from 'react';
|
|
4
|
+
|
|
5
|
+
export function UploadIcon(props: SVGProps<SVGSVGElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
strokeWidth="2"
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<path d="M12 3v12" />
|
|
18
|
+
<path d="m17 8-5-5-5 5" />
|
|
19
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.group {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: var(--sv-space-2);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.label {
|
|
7
|
+
font-size: var(--sv-font-size-xs);
|
|
8
|
+
font-weight: var(--sv-font-weight-semibold);
|
|
9
|
+
color: var(--sv-color-text-muted);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.row {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
gap: var(--sv-space-2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.trailing {
|
|
20
|
+
flex-shrink: 0;
|
|
21
|
+
max-width: 8rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hint {
|
|
25
|
+
font-size: var(--sv-font-size-xs);
|
|
26
|
+
color: var(--sv-color-text-muted);
|
|
27
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { CurrencyInput } from '../CurrencyInput/CurrencyInput';
|
|
4
|
+
import { MemberMultiSelect, type MemberMultiSelectOption } from './MemberMultiSelect';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/MemberMultiSelect',
|
|
8
|
+
component: MemberMultiSelect,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'padded',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component:
|
|
14
|
+
'Checkbox list for picking any number of people from an already-resolved set of options. Domain-agnostic about who an option represents — instance users and guest members are both just `{id, label}`, so nothing special is needed to include guests. Not a directory search or invite flow; pair with SuggestionInput for that.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
options: [],
|
|
20
|
+
selectedIds: new Set(),
|
|
21
|
+
onToggle: () => {},
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof MemberMultiSelect>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
const OPTIONS: MemberMultiSelectOption[] = [
|
|
29
|
+
{ id: '1', label: 'Priya' },
|
|
30
|
+
{ id: '2', label: 'Jamie (guest)' },
|
|
31
|
+
{ id: '3', label: 'Sam' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function ControlledMultiSelect({ withTrailing = false }: { withTrailing?: boolean }) {
|
|
35
|
+
const [selected, setSelected] = useState<Set<string>>(new Set(['1', '3']));
|
|
36
|
+
const [amounts, setAmounts] = useState<Record<string, number | null>>({});
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<MemberMultiSelect
|
|
40
|
+
options={OPTIONS}
|
|
41
|
+
selectedIds={selected}
|
|
42
|
+
onToggle={(id, checked) => {
|
|
43
|
+
setSelected((prev) => {
|
|
44
|
+
const next = new Set(prev);
|
|
45
|
+
if (checked) next.add(id);
|
|
46
|
+
else next.delete(id);
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
}}
|
|
50
|
+
label="Split between"
|
|
51
|
+
hint={withTrailing ? 'Remaining: 0.00' : undefined}
|
|
52
|
+
renderTrailing={
|
|
53
|
+
withTrailing
|
|
54
|
+
? (id) => (
|
|
55
|
+
<CurrencyInput
|
|
56
|
+
valueCents={amounts[id] ?? null}
|
|
57
|
+
onValueChange={(cents) => setAmounts((prev) => ({ ...prev, [id]: cents }))}
|
|
58
|
+
placeholder="0.00"
|
|
59
|
+
aria-label={`Amount for ${OPTIONS.find((o) => o.id === id)?.label}`}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
: undefined
|
|
63
|
+
}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const Default: Story = {
|
|
69
|
+
render: () => <ControlledMultiSelect />,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const WithTrailingAmountInput: Story = {
|
|
73
|
+
render: () => <ControlledMultiSelect withTrailing />,
|
|
74
|
+
};
|