@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.
Files changed (29) hide show
  1. package/dist/BalanceChip.module.css +32 -0
  2. package/dist/FileDropzone.module.css +63 -0
  3. package/dist/MemberMultiSelect.module.css +27 -0
  4. package/dist/index.d.ts +151 -3
  5. package/dist/index.js +630 -370
  6. package/package.json +1 -1
  7. package/src/components/BalanceChip/BalanceChip.module.css +32 -0
  8. package/src/components/BalanceChip/BalanceChip.stories.tsx +45 -0
  9. package/src/components/BalanceChip/BalanceChip.tsx +33 -0
  10. package/src/components/CurrencyInput/CurrencyInput.stories.tsx +50 -0
  11. package/src/components/CurrencyInput/CurrencyInput.tsx +60 -0
  12. package/src/components/FileDropzone/FileDropzone.module.css +63 -0
  13. package/src/components/FileDropzone/FileDropzone.tsx +109 -0
  14. package/src/components/FileDropzone/__tests__/FileDropzone.test.tsx +67 -0
  15. package/src/components/Icon/icons/external-link.tsx +22 -0
  16. package/src/components/Icon/icons/file.tsx +21 -0
  17. package/src/components/Icon/icons/index.ts +12 -0
  18. package/src/components/Icon/icons/upload.tsx +22 -0
  19. package/src/components/MemberMultiSelect/MemberMultiSelect.module.css +27 -0
  20. package/src/components/MemberMultiSelect/MemberMultiSelect.stories.tsx +74 -0
  21. package/src/components/MemberMultiSelect/MemberMultiSelect.tsx +67 -0
  22. package/src/components/SplitMethodSelector/SplitMethodSelector.stories.tsx +33 -0
  23. package/src/components/SplitMethodSelector/SplitMethodSelector.tsx +43 -0
  24. package/src/hooks/index.ts +2 -0
  25. package/src/hooks/useCommitOnEnterOrBlur.ts +39 -0
  26. package/src/index.ts +24 -2
  27. package/src/stories/DesignSystemOverview.stories.tsx +119 -1
  28. package/src/stories/FileDropzone.stories.tsx +69 -0
  29. package/src/stories/InteractionHooks.stories.tsx +55 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.37.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "description": "Sovereign Design System — design tokens and React components.",
6
6
  "keywords": [
@@ -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
+ };