@sovereignfs/ui 0.40.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.
@@ -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,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
+ }
package/dist/index.d.ts CHANGED
@@ -912,6 +912,87 @@ interface CheckableListRowProps {
912
912
  */
913
913
  declare function CheckableListRow({ checked, onCheckedChange, label, icon, trailing, disabled, className, }: CheckableListRowProps): react.JSX.Element;
914
914
 
915
+ interface CurrencyInputProps extends Omit<InputProps, 'value' | 'onChange' | 'type' | 'inputMode'> {
916
+ /** Smallest currency unit (cents) — never a float. Null when the field is empty or unparsable. */
917
+ valueCents: number | null;
918
+ onValueChange: (cents: number | null) => void;
919
+ }
920
+ /**
921
+ * CurrencyInput — decimal amount entry that reports its value as integer
922
+ * cents, matching Sovereign's "amounts are always smallest-unit integers"
923
+ * data-model convention (never a float in application state).
924
+ *
925
+ * Keeps its own text buffer rather than deriving display text from
926
+ * `valueCents` on every keystroke — reformatting mid-type (e.g. "12." → the
927
+ * trailing decimal point) would fight the user's cursor. It only
928
+ * re-syncs from `valueCents` when the prop changes to something the current
929
+ * text doesn't already represent (an external reset/prefill), so typing
930
+ * remains uninterrupted while the parsed amount stays in lockstep with the
931
+ * parent's state.
932
+ */
933
+ declare function CurrencyInput({ valueCents, onValueChange, ...rest }: CurrencyInputProps): react.JSX.Element;
934
+
935
+ interface BalanceChipProps {
936
+ /** Smallest currency unit (cents). Positive = owed to them, negative = they owe, zero = settled. */
937
+ amountCents: number;
938
+ /** ISO 4217 currency code, e.g. "USD". */
939
+ currency: string;
940
+ className?: string;
941
+ }
942
+ /**
943
+ * BalanceChip — inline balance indicator (green = owed to them, red = they
944
+ * owe, neutral = settled up). For any plugin tracking who-owes-whom — not
945
+ * Tally-specific despite the naming; "amountCents" is a generic signed net
946
+ * balance, not tied to expense-splitting semantics.
947
+ */
948
+ declare function BalanceChip({ amountCents, currency, className }: BalanceChipProps): react.JSX.Element;
949
+
950
+ type SplitMethod = 'equal' | 'amount' | 'percentage' | 'shares';
951
+ interface SplitMethodSelectorProps {
952
+ value: SplitMethod;
953
+ onChange: (value: SplitMethod) => void;
954
+ size?: 'sm' | 'md';
955
+ 'aria-label'?: string;
956
+ }
957
+ /**
958
+ * SplitMethodSelector — the four-way equal/amount/percentage/shares picker
959
+ * shared by any plugin that splits a cost between people. A thin
960
+ * `SegmentedControl` preset: centralizes the option list and labels so every
961
+ * expense-splitting plugin presents the same four choices in the same order,
962
+ * instead of each one re-typing its own copy of this array.
963
+ */
964
+ declare function SplitMethodSelector({ value, onChange, size, 'aria-label': ariaLabel, }: SplitMethodSelectorProps): react.JSX.Element;
965
+
966
+ interface MemberMultiSelectOption {
967
+ id: string;
968
+ label: string;
969
+ }
970
+ interface MemberMultiSelectProps {
971
+ options: MemberMultiSelectOption[];
972
+ selectedIds: Set<string>;
973
+ onToggle: (id: string, checked: boolean) => void;
974
+ /** Rendered to the right of a selected row only — e.g. a per-person `CurrencyInput` or share count. */
975
+ renderTrailing?: (id: string) => ReactNode;
976
+ /** Section label above the list, e.g. "Paid by" or "Split between". */
977
+ label?: string;
978
+ /** Helper/validation text below the list, e.g. a running total. */
979
+ hint?: ReactNode;
980
+ className?: string;
981
+ }
982
+ /**
983
+ * MemberMultiSelect — checkbox list for picking any number of people from an
984
+ * already-resolved set of options (group/project members, collaborators,
985
+ * etc.). Deliberately domain-agnostic about *who* an option represents —
986
+ * instance users and guest members are both just `{id, label}` here, so
987
+ * nothing special is needed to include guests alongside instance users; the
988
+ * caller's `options` array is the only place that distinction exists.
989
+ *
990
+ * Not a directory search or an "add a new guest" flow — pair with
991
+ * `SuggestionInput` (search + add) for that; this component only selects
992
+ * among options already passed in.
993
+ */
994
+ declare function MemberMultiSelect({ options, selectedIds, onToggle, renderTrailing, label, hint, className, }: MemberMultiSelectProps): react.JSX.Element;
995
+
915
996
  interface UseLongPressOptions {
916
997
  /** Called once the press has been held for `delay` without exceeding `moveTolerance`. */
917
998
  onLongPress: () => void;
@@ -1046,4 +1127,4 @@ interface CommitOnEnterOrBlurHandlers {
1046
1127
  */
1047
1128
  declare function useCommitOnEnterOrBlur(onCommit: () => void): CommitOnEnterOrBlurHandlers;
1048
1129
 
1049
- export { Avatar, type AvatarProps, Badge, type BadgeProps, type BadgeStatus, type BadgeVariant, type BannerVariant, Button, type ButtonProps, type ButtonSize, type ButtonVariant, Calendar, type CalendarProps, Card, type CardProps, CheckableListRow, type CheckableListRowProps, Checkbox, type CheckboxProps, CodeTextarea, type CodeTextareaProps, type CommitOnEnterOrBlurHandlers, ConfirmDialog, type ConfirmDialogProps, DatePicker, type DatePickerProps, Dialog, type DialogProps, type DialogSize, DragHandleRow, type DragHandleRowProps, Drawer, type DrawerProps, EmptyState, type EmptyStateProps, FileDropzone, type FileDropzoneProps, FormField, type FormFieldProps, type FormFieldRenderProps, Icon, type IconName, IconPicker, type IconPickerProps, type IconProps, Input, type InputProps, type LongPressHandlers, MOBILE_BREAKPOINT_PX, Menu, type MenuEntry, type MenuItem, type MenuLabel, type MenuProps, type MenuSeparator, type NavTabItem, NavTabs, type NavTabsProps, OverlayHeader, type OverlayHeaderProps, PageHeader, type PageHeaderProps, Popover, type PopoverProps, QuantityStepper, type QuantityStepperProps, SegmentedControl, type SegmentedControlProps, type SegmentedOption, Select, type SelectProps, Sheet, type SheetProps, Spinner, type SpinnerProps, SplitPane, type SplitPaneProps, StatusBadge, type StatusBadgeProps, type StatusBadgeStatus, SuggestionInput, type SuggestionInputProps, type SuggestionOption, SystemBanner, type SystemBannerProps, type TabItem, Tabs, type TabsProps, TagInput, type TagInputProps, Textarea, type TextareaProps, type ToastContextValue, type ToastItem, ToastProvider, Toggle, type ToggleProps, Tooltip, type TooltipProps, type UseLongPressOptions, useCommitOnEnterOrBlur, useDoubleTapHandler, useIsMobile, useLongPress, useOverlaySecondRow, useSingleOrDoubleTap, useToast };
1130
+ export { Avatar, type AvatarProps, Badge, type BadgeProps, type BadgeStatus, type BadgeVariant, BalanceChip, type BalanceChipProps, type BannerVariant, Button, type ButtonProps, type ButtonSize, type ButtonVariant, Calendar, type CalendarProps, Card, type CardProps, CheckableListRow, type CheckableListRowProps, Checkbox, type CheckboxProps, CodeTextarea, type CodeTextareaProps, type CommitOnEnterOrBlurHandlers, ConfirmDialog, type ConfirmDialogProps, CurrencyInput, type CurrencyInputProps, DatePicker, type DatePickerProps, Dialog, type DialogProps, type DialogSize, DragHandleRow, type DragHandleRowProps, Drawer, type DrawerProps, EmptyState, type EmptyStateProps, FileDropzone, type FileDropzoneProps, FormField, type FormFieldProps, type FormFieldRenderProps, Icon, type IconName, IconPicker, type IconPickerProps, type IconProps, Input, type InputProps, type LongPressHandlers, MOBILE_BREAKPOINT_PX, MemberMultiSelect, type MemberMultiSelectOption, type MemberMultiSelectProps, Menu, type MenuEntry, type MenuItem, type MenuLabel, type MenuProps, type MenuSeparator, type NavTabItem, NavTabs, type NavTabsProps, OverlayHeader, type OverlayHeaderProps, PageHeader, type PageHeaderProps, Popover, type PopoverProps, QuantityStepper, type QuantityStepperProps, SegmentedControl, type SegmentedControlProps, type SegmentedOption, Select, type SelectProps, Sheet, type SheetProps, Spinner, type SpinnerProps, type SplitMethod, SplitMethodSelector, type SplitMethodSelectorProps, SplitPane, type SplitPaneProps, StatusBadge, type StatusBadgeProps, type StatusBadgeStatus, SuggestionInput, type SuggestionInputProps, type SuggestionOption, SystemBanner, type SystemBannerProps, type TabItem, Tabs, type TabsProps, TagInput, type TagInputProps, Textarea, type TextareaProps, type ToastContextValue, type ToastItem, ToastProvider, Toggle, type ToggleProps, Tooltip, type TooltipProps, type UseLongPressOptions, useCommitOnEnterOrBlur, useDoubleTapHandler, useIsMobile, useLongPress, useOverlaySecondRow, useSingleOrDoubleTap, useToast };
package/dist/index.js CHANGED
@@ -3636,9 +3636,114 @@ function CheckableListRow({
3636
3636
  trailing && /* @__PURE__ */ jsx91("span", { className: styles39.trailing, children: trailing })
3637
3637
  ] });
3638
3638
  }
3639
+
3640
+ // src/components/CurrencyInput/CurrencyInput.tsx
3641
+ import { useState as useState15 } from "react";
3642
+ import { jsx as jsx92 } from "react/jsx-runtime";
3643
+ function centsToText(cents) {
3644
+ return (cents / 100).toFixed(2);
3645
+ }
3646
+ function textToCents(text) {
3647
+ const trimmed = text.trim();
3648
+ if (!trimmed) return null;
3649
+ const parsed = Number(trimmed);
3650
+ if (!Number.isFinite(parsed)) return null;
3651
+ return Math.round(parsed * 100);
3652
+ }
3653
+ function CurrencyInput({ valueCents, onValueChange, ...rest }) {
3654
+ const [text, setText] = useState15(() => valueCents != null ? centsToText(valueCents) : "");
3655
+ if (textToCents(text) !== valueCents) {
3656
+ setText(valueCents != null ? centsToText(valueCents) : "");
3657
+ }
3658
+ return /* @__PURE__ */ jsx92(
3659
+ Input,
3660
+ {
3661
+ ...rest,
3662
+ type: "text",
3663
+ inputMode: "decimal",
3664
+ value: text,
3665
+ onChange: (e) => {
3666
+ const next = e.currentTarget.value;
3667
+ setText(next);
3668
+ onValueChange(textToCents(next));
3669
+ }
3670
+ }
3671
+ );
3672
+ }
3673
+
3674
+ // src/components/BalanceChip/BalanceChip.tsx
3675
+ import styles40 from "./BalanceChip.module.css";
3676
+ import { jsx as jsx93 } from "react/jsx-runtime";
3677
+ function formatAbsCents(cents) {
3678
+ return (Math.abs(cents) / 100).toFixed(2);
3679
+ }
3680
+ function BalanceChip({ amountCents, currency, className }) {
3681
+ const direction = amountCents > 0 ? "owed" : amountCents < 0 ? "owes" : "settled";
3682
+ const text = direction === "settled" ? "Settled up" : `${direction === "owed" ? "Owed" : "Owes"} ${currency} ${formatAbsCents(amountCents)}`;
3683
+ return /* @__PURE__ */ jsx93("span", { className: [styles40.chip, styles40[direction], className].filter(Boolean).join(" "), children: text });
3684
+ }
3685
+
3686
+ // src/components/SplitMethodSelector/SplitMethodSelector.tsx
3687
+ import { jsx as jsx94 } from "react/jsx-runtime";
3688
+ var OPTIONS = [
3689
+ { label: "Equal", value: "equal" },
3690
+ { label: "Amount", value: "amount" },
3691
+ { label: "Percentage", value: "percentage" },
3692
+ { label: "Shares", value: "shares" }
3693
+ ];
3694
+ function SplitMethodSelector({
3695
+ value,
3696
+ onChange,
3697
+ size = "sm",
3698
+ "aria-label": ariaLabel = "Split method"
3699
+ }) {
3700
+ return /* @__PURE__ */ jsx94(
3701
+ SegmentedControl,
3702
+ {
3703
+ value,
3704
+ onChange,
3705
+ options: OPTIONS,
3706
+ size,
3707
+ "aria-label": ariaLabel
3708
+ }
3709
+ );
3710
+ }
3711
+
3712
+ // src/components/MemberMultiSelect/MemberMultiSelect.tsx
3713
+ import styles41 from "./MemberMultiSelect.module.css";
3714
+ import { jsx as jsx95, jsxs as jsxs73 } from "react/jsx-runtime";
3715
+ function MemberMultiSelect({
3716
+ options,
3717
+ selectedIds,
3718
+ onToggle,
3719
+ renderTrailing,
3720
+ label,
3721
+ hint,
3722
+ className
3723
+ }) {
3724
+ return /* @__PURE__ */ jsxs73("div", { className: [styles41.group, className].filter(Boolean).join(" "), children: [
3725
+ label && /* @__PURE__ */ jsx95("span", { className: styles41.label, children: label }),
3726
+ options.map((option) => {
3727
+ const checked = selectedIds.has(option.id);
3728
+ return /* @__PURE__ */ jsxs73("div", { className: styles41.row, children: [
3729
+ /* @__PURE__ */ jsx95(
3730
+ Checkbox,
3731
+ {
3732
+ checked,
3733
+ onChange: (next) => onToggle(option.id, next),
3734
+ label: option.label
3735
+ }
3736
+ ),
3737
+ checked && renderTrailing && /* @__PURE__ */ jsx95("span", { className: styles41.trailing, children: renderTrailing(option.id) })
3738
+ ] }, option.id);
3739
+ }),
3740
+ hint && /* @__PURE__ */ jsx95("span", { className: styles41.hint, children: hint })
3741
+ ] });
3742
+ }
3639
3743
  export {
3640
3744
  Avatar,
3641
3745
  Badge,
3746
+ BalanceChip,
3642
3747
  Button,
3643
3748
  Calendar,
3644
3749
  Card,
@@ -3646,6 +3751,7 @@ export {
3646
3751
  Checkbox,
3647
3752
  CodeTextarea,
3648
3753
  ConfirmDialog,
3754
+ CurrencyInput,
3649
3755
  DatePicker,
3650
3756
  Dialog,
3651
3757
  DragHandleRow,
@@ -3657,6 +3763,7 @@ export {
3657
3763
  IconPicker,
3658
3764
  Input,
3659
3765
  MOBILE_BREAKPOINT_PX,
3766
+ MemberMultiSelect,
3660
3767
  Menu,
3661
3768
  NavTabs,
3662
3769
  OverlayHeader,
@@ -3667,6 +3774,7 @@ export {
3667
3774
  Select,
3668
3775
  Sheet,
3669
3776
  Spinner,
3777
+ SplitMethodSelector,
3670
3778
  SplitPane,
3671
3779
  StatusBadge,
3672
3780
  SuggestionInput,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovereignfs/ui",
3
- "version": "0.40.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,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
+ };
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import type { ReactNode } from 'react';
4
+ import { Checkbox } from '../Checkbox/Checkbox';
5
+ import styles from './MemberMultiSelect.module.css';
6
+
7
+ export interface MemberMultiSelectOption {
8
+ id: string;
9
+ label: string;
10
+ }
11
+
12
+ export interface MemberMultiSelectProps {
13
+ options: MemberMultiSelectOption[];
14
+ selectedIds: Set<string>;
15
+ onToggle: (id: string, checked: boolean) => void;
16
+ /** Rendered to the right of a selected row only — e.g. a per-person `CurrencyInput` or share count. */
17
+ renderTrailing?: (id: string) => ReactNode;
18
+ /** Section label above the list, e.g. "Paid by" or "Split between". */
19
+ label?: string;
20
+ /** Helper/validation text below the list, e.g. a running total. */
21
+ hint?: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * MemberMultiSelect — checkbox list for picking any number of people from an
27
+ * already-resolved set of options (group/project members, collaborators,
28
+ * etc.). Deliberately domain-agnostic about *who* an option represents —
29
+ * instance users and guest members are both just `{id, label}` here, so
30
+ * nothing special is needed to include guests alongside instance users; the
31
+ * caller's `options` array is the only place that distinction exists.
32
+ *
33
+ * Not a directory search or an "add a new guest" flow — pair with
34
+ * `SuggestionInput` (search + add) for that; this component only selects
35
+ * among options already passed in.
36
+ */
37
+ export function MemberMultiSelect({
38
+ options,
39
+ selectedIds,
40
+ onToggle,
41
+ renderTrailing,
42
+ label,
43
+ hint,
44
+ className,
45
+ }: MemberMultiSelectProps) {
46
+ return (
47
+ <div className={[styles.group, className].filter(Boolean).join(' ')}>
48
+ {label && <span className={styles.label}>{label}</span>}
49
+ {options.map((option) => {
50
+ const checked = selectedIds.has(option.id);
51
+ return (
52
+ <div key={option.id} className={styles.row}>
53
+ <Checkbox
54
+ checked={checked}
55
+ onChange={(next) => onToggle(option.id, next)}
56
+ label={option.label}
57
+ />
58
+ {checked && renderTrailing && (
59
+ <span className={styles.trailing}>{renderTrailing(option.id)}</span>
60
+ )}
61
+ </div>
62
+ );
63
+ })}
64
+ {hint && <span className={styles.hint}>{hint}</span>}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,33 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { SplitMethodSelector, type SplitMethod } from './SplitMethodSelector';
4
+
5
+ const meta = {
6
+ title: 'Components/SplitMethodSelector',
7
+ component: SplitMethodSelector,
8
+ parameters: {
9
+ layout: 'centered',
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'The four-way Equal/Amount/Percentage/Shares picker shared by any cost-splitting plugin. A thin SegmentedControl preset that centralizes the option list and labels.',
14
+ },
15
+ },
16
+ },
17
+ args: {
18
+ value: 'equal',
19
+ onChange: () => {},
20
+ },
21
+ } satisfies Meta<typeof SplitMethodSelector>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ function ControlledSelector() {
27
+ const [value, setValue] = useState<SplitMethod>('equal');
28
+ return <SplitMethodSelector value={value} onChange={setValue} />;
29
+ }
30
+
31
+ export const Default: Story = {
32
+ render: () => <ControlledSelector />,
33
+ };
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import { SegmentedControl } from '../SegmentedControl/SegmentedControl';
4
+
5
+ export type SplitMethod = 'equal' | 'amount' | 'percentage' | 'shares';
6
+
7
+ export interface SplitMethodSelectorProps {
8
+ value: SplitMethod;
9
+ onChange: (value: SplitMethod) => void;
10
+ size?: 'sm' | 'md';
11
+ 'aria-label'?: string;
12
+ }
13
+
14
+ const OPTIONS: { label: string; value: SplitMethod }[] = [
15
+ { label: 'Equal', value: 'equal' },
16
+ { label: 'Amount', value: 'amount' },
17
+ { label: 'Percentage', value: 'percentage' },
18
+ { label: 'Shares', value: 'shares' },
19
+ ];
20
+
21
+ /**
22
+ * SplitMethodSelector — the four-way equal/amount/percentage/shares picker
23
+ * shared by any plugin that splits a cost between people. A thin
24
+ * `SegmentedControl` preset: centralizes the option list and labels so every
25
+ * expense-splitting plugin presents the same four choices in the same order,
26
+ * instead of each one re-typing its own copy of this array.
27
+ */
28
+ export function SplitMethodSelector({
29
+ value,
30
+ onChange,
31
+ size = 'sm',
32
+ 'aria-label': ariaLabel = 'Split method',
33
+ }: SplitMethodSelectorProps) {
34
+ return (
35
+ <SegmentedControl
36
+ value={value}
37
+ onChange={onChange}
38
+ options={OPTIONS}
39
+ size={size}
40
+ aria-label={ariaLabel}
41
+ />
42
+ );
43
+ }
package/src/index.ts CHANGED
@@ -88,6 +88,20 @@ export { QuantityStepper } from './components/QuantityStepper/QuantityStepper';
88
88
  export type { QuantityStepperProps } from './components/QuantityStepper/QuantityStepper';
89
89
  export { CheckableListRow } from './components/CheckableListRow/CheckableListRow';
90
90
  export type { CheckableListRowProps } from './components/CheckableListRow/CheckableListRow';
91
+ export { CurrencyInput } from './components/CurrencyInput/CurrencyInput';
92
+ export type { CurrencyInputProps } from './components/CurrencyInput/CurrencyInput';
93
+ export { BalanceChip } from './components/BalanceChip/BalanceChip';
94
+ export type { BalanceChipProps } from './components/BalanceChip/BalanceChip';
95
+ export { SplitMethodSelector } from './components/SplitMethodSelector/SplitMethodSelector';
96
+ export type {
97
+ SplitMethodSelectorProps,
98
+ SplitMethod,
99
+ } from './components/SplitMethodSelector/SplitMethodSelector';
100
+ export { MemberMultiSelect } from './components/MemberMultiSelect/MemberMultiSelect';
101
+ export type {
102
+ MemberMultiSelectProps,
103
+ MemberMultiSelectOption,
104
+ } from './components/MemberMultiSelect/MemberMultiSelect';
91
105
  export {
92
106
  useLongPress,
93
107
  useDoubleTapHandler,
@@ -41,6 +41,16 @@ import { ConfirmDialog } from '../components/ConfirmDialog/ConfirmDialog';
41
41
  import { Menu } from '../components/Menu/Menu';
42
42
  import { Calendar } from '../components/Calendar/Calendar';
43
43
  import { DatePicker } from '../components/DatePicker/DatePicker';
44
+ import { CurrencyInput } from '../components/CurrencyInput/CurrencyInput';
45
+ import { BalanceChip } from '../components/BalanceChip/BalanceChip';
46
+ import {
47
+ SplitMethodSelector,
48
+ type SplitMethod,
49
+ } from '../components/SplitMethodSelector/SplitMethodSelector';
50
+ import {
51
+ MemberMultiSelect,
52
+ type MemberMultiSelectOption,
53
+ } from '../components/MemberMultiSelect/MemberMultiSelect';
44
54
 
45
55
  // ---------------------------------------------------------------------------
46
56
  // Shared primitives
@@ -756,6 +766,50 @@ function CheckableListRowDemo() {
756
766
  );
757
767
  }
758
768
 
769
+ function CurrencyInputDemo() {
770
+ const [cents, setCents] = useState<number | null>(4250);
771
+ return (
772
+ <CurrencyInput
773
+ valueCents={cents}
774
+ onValueChange={setCents}
775
+ placeholder="0.00"
776
+ aria-label="Amount"
777
+ />
778
+ );
779
+ }
780
+
781
+ function SplitMethodSelectorDemo() {
782
+ const [value, setValue] = useState<SplitMethod>('equal');
783
+ return <SplitMethodSelector value={value} onChange={setValue} />;
784
+ }
785
+
786
+ const MEMBER_MULTI_SELECT_OPTIONS: MemberMultiSelectOption[] = [
787
+ { id: '1', label: 'Priya' },
788
+ { id: '2', label: 'Jamie (guest)' },
789
+ { id: '3', label: 'Sam' },
790
+ ];
791
+
792
+ function MemberMultiSelectDemo() {
793
+ const [selected, setSelected] = useState<Set<string>>(new Set(['1', '3']));
794
+ return (
795
+ <div style={{ width: '100%' }}>
796
+ <MemberMultiSelect
797
+ options={MEMBER_MULTI_SELECT_OPTIONS}
798
+ selectedIds={selected}
799
+ onToggle={(id, checked) => {
800
+ setSelected((prev) => {
801
+ const next = new Set(prev);
802
+ if (checked) next.add(id);
803
+ else next.delete(id);
804
+ return next;
805
+ });
806
+ }}
807
+ label="Split between"
808
+ />
809
+ </div>
810
+ );
811
+ }
812
+
759
813
  function FileDropzoneDemo() {
760
814
  const [file, setFile] = useState<File | null>(null);
761
815
  return (
@@ -1493,6 +1547,17 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1493
1547
  <StatusBadge status="pending-delete" />
1494
1548
  </ComponentCard>
1495
1549
 
1550
+ {/* BalanceChip */}
1551
+ <ComponentCard
1552
+ name="BalanceChip"
1553
+ importLine="import { BalanceChip } from '@sovereignfs/ui';"
1554
+ usage="Inline net-balance indicator — green when owed to them, red when they owe, neutral when settled. Not tied to expense-splitting specifically."
1555
+ >
1556
+ <BalanceChip amountCents={2500} currency="USD" />
1557
+ <BalanceChip amountCents={-1350} currency="USD" />
1558
+ <BalanceChip amountCents={0} currency="USD" />
1559
+ </ComponentCard>
1560
+
1496
1561
  {/* Input */}
1497
1562
  <ComponentCard
1498
1563
  name="Input"
@@ -1505,6 +1570,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1505
1570
  </div>
1506
1571
  </ComponentCard>
1507
1572
 
1573
+ {/* CurrencyInput */}
1574
+ <ComponentCard
1575
+ name="CurrencyInput"
1576
+ importLine="import { CurrencyInput } from '@sovereignfs/ui';"
1577
+ usage="Decimal amount entry that reports its value as integer cents. Preserves in-progress typing instead of reformatting on every keystroke."
1578
+ >
1579
+ <CurrencyInputDemo />
1580
+ </ComponentCard>
1581
+
1508
1582
  {/* TagInput */}
1509
1583
  <ComponentCard
1510
1584
  name="TagInput"
@@ -1585,6 +1659,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1585
1659
  <SegmentedDemo />
1586
1660
  </ComponentCard>
1587
1661
 
1662
+ {/* SplitMethodSelector */}
1663
+ <ComponentCard
1664
+ name="SplitMethodSelector"
1665
+ importLine="import { SplitMethodSelector } from '@sovereignfs/ui';"
1666
+ usage="The four-way Equal/Amount/Percentage/Shares picker shared by any cost-splitting plugin. A thin SegmentedControl preset."
1667
+ >
1668
+ <SplitMethodSelectorDemo />
1669
+ </ComponentCard>
1670
+
1588
1671
  {/* Tabs */}
1589
1672
  <ComponentCard
1590
1673
  name="Tabs"
@@ -1922,6 +2005,15 @@ font-weight: var(--sv-font-weight-bold); /* 700 */`}</Code>
1922
2005
  <CheckableListRowDemo />
1923
2006
  </ComponentCard>
1924
2007
 
2008
+ {/* MemberMultiSelect */}
2009
+ <ComponentCard
2010
+ name="MemberMultiSelect"
2011
+ importLine="import { MemberMultiSelect } from '@sovereignfs/ui';"
2012
+ usage="Checkbox list for picking any number of people from an already-resolved option set. Domain-agnostic about guest vs. instance-user options; pair with SuggestionInput for search + add."
2013
+ >
2014
+ <MemberMultiSelectDemo />
2015
+ </ComponentCard>
2016
+
1925
2017
  {/* Checkbox */}
1926
2018
  <ComponentCard
1927
2019
  name="Checkbox"