@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.
- package/dist/BalanceChip.module.css +32 -0
- package/dist/MemberMultiSelect.module.css +27 -0
- package/dist/index.d.ts +82 -1
- package/dist/index.js +108 -0
- 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/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/index.ts +14 -0
- package/src/stories/DesignSystemOverview.stories.tsx +92 -0
|
@@ -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
|
@@ -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"
|