feat(web): Mercury DS components + base i18n
This commit is contained in:
parent
3fd45fe54d
commit
83c5ffbdaf
10 changed files with 359 additions and 0 deletions
14
src/ds/AmountToggle.test.tsx
Normal file
14
src/ds/AmountToggle.test.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { it, expect } from "vitest";
|
||||
import { AmountToggle } from "./AmountToggle";
|
||||
|
||||
it("hides then reveals amount on click", () => {
|
||||
render(<AmountToggle value="52000" />);
|
||||
const el = screen.getByRole("button");
|
||||
const first = el.textContent;
|
||||
expect(["52,000₮", "••••••"]).toContain(first);
|
||||
fireEvent.click(el);
|
||||
const second = el.textContent;
|
||||
expect(["52,000₮", "••••••"]).toContain(second);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
30
src/ds/AmountToggle.tsx
Normal file
30
src/ds/AmountToggle.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { tugrikRaw, MASKED } from "./money";
|
||||
|
||||
export interface AmountToggleProps {
|
||||
value: string | number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap-to-reveal amount, independent of the global hide-amounts flag
|
||||
* (mirrors iOS `TapAmount`). Starts revealed; each click flips its own
|
||||
* local hidden state.
|
||||
*/
|
||||
export function AmountToggle({ value, className }: AmountToggleProps) {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
aria-pressed={hidden}
|
||||
onClick={() => setHidden((h) => !h)}
|
||||
style={{ background: "none", border: "none", padding: 0, font: "inherit", cursor: "pointer" }}
|
||||
>
|
||||
{hidden ? MASKED : tugrikRaw(value)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
src/ds/Card.tsx
Normal file
21
src/ds/Card.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from "react";
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/** Surface container: Seed's floating layer background + r3 radius (12px),
|
||||
* the same shape used by Mercury's cards on iOS (`SeedRadius.r3`). */
|
||||
export function Card({ style, children, ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--seed-color-bg-layer-floating)",
|
||||
borderRadius: "var(--seed-radius-r3)",
|
||||
padding: 16,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/ds/HideAmountsToggle.tsx
Normal file
34
src/ds/HideAmountsToggle.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel } from "@seed-design/react";
|
||||
import { isHidden, setHidden } from "./money";
|
||||
|
||||
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
|
||||
* pages that render amounts elsewhere (not through this switch) can re-render. */
|
||||
export const HIDE_AMOUNTS_EVENT = "mercury:hideamounts";
|
||||
|
||||
export interface HideAmountsToggleProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: HideAmountsToggleProps) {
|
||||
const [checked, setChecked] = useState<boolean>(() => isHidden());
|
||||
|
||||
function handleChange(next: boolean) {
|
||||
setHidden(next);
|
||||
setChecked(next);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(HIDE_AMOUNTS_EVENT, { detail: { hidden: next } }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SwitchRoot checked={checked} onCheckedChange={handleChange}>
|
||||
<SwitchControl>
|
||||
<SwitchThumb />
|
||||
</SwitchControl>
|
||||
<SwitchLabel>{label}</SwitchLabel>
|
||||
</SwitchRoot>
|
||||
);
|
||||
}
|
||||
27
src/ds/MercuryButton.test.tsx
Normal file
27
src/ds/MercuryButton.test.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { it, expect, vi } from "vitest";
|
||||
|
||||
// `@seed-design/react`'s barrel re-exports every Seed component, each of
|
||||
// which imports its own CSS recipe module. Those recipe modules do a
|
||||
// side-effect `import "./xxx.css"`. Vitest's worker pool runs under real
|
||||
// Node.js even when invoked via `bunx` (its bin has a `#!/usr/bin/env node`
|
||||
// shebang), and plain Node's ESM loader has no CSS handling — only a
|
||||
// bundler (Next.js's webpack, which the real app runs under) knows how to
|
||||
// process those. So the real package cannot be loaded in this test
|
||||
// process; stub just the piece MercuryButton uses. (Confirmed via
|
||||
// `node -e "import('@seed-design/react')"` failing with
|
||||
// `ERR_UNKNOWN_FILE_EXTENSION` for `.css`, while `bun -e` — a different
|
||||
// runtime than vitest's worker — succeeds.)
|
||||
vi.mock("@seed-design/react", () => ({
|
||||
ActionButton: ({ children, ...props }: Record<string, unknown>) => (
|
||||
<button {...props}>{children as React.ReactNode}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { MercuryButton } from "./MercuryButton";
|
||||
|
||||
it("renders yellow primary CTA", () => {
|
||||
render(<MercuryButton variant="primary">Холбох</MercuryButton>);
|
||||
const btn = screen.getByRole("button", { name: "Холбох" });
|
||||
expect(btn).toBeInTheDocument();
|
||||
});
|
||||
45
src/ds/MercuryButton.tsx
Normal file
45
src/ds/MercuryButton.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ActionButton, type ActionButtonProps } from "@seed-design/react";
|
||||
|
||||
export type MercuryButtonVariant = "primary" | "secondary" | "ghost";
|
||||
|
||||
export interface MercuryButtonProps
|
||||
extends Omit<ActionButtonProps, "variant" | "color"> {
|
||||
variant?: MercuryButtonVariant;
|
||||
}
|
||||
|
||||
// Map Mercury's three brand-facing variants onto Seed's neutral variants.
|
||||
// `primary` reuses `neutralSolid`'s shape/sizing but overrides fill/label
|
||||
// color inline to the fixed Mercury brand yellow (not theme-dynamic — same
|
||||
// #FFEB02/black pairing in light and dark, mirroring MercuryPrimaryButton.swift).
|
||||
const seedVariant: Record<MercuryButtonVariant, ActionButtonProps["variant"]> = {
|
||||
primary: "neutralSolid",
|
||||
secondary: "neutralOutline",
|
||||
ghost: "ghost",
|
||||
};
|
||||
|
||||
export function MercuryButton({
|
||||
variant = "primary",
|
||||
style,
|
||||
...props
|
||||
}: MercuryButtonProps) {
|
||||
const primaryStyle: React.CSSProperties | undefined =
|
||||
variant === "primary"
|
||||
? {
|
||||
background: "var(--mercury-brand-yellow)",
|
||||
color: "var(--mercury-on-brand)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
variant={seedVariant[variant]}
|
||||
size="large"
|
||||
style={{ ...primaryStyle, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
64
src/ds/NameEdit.tsx
Normal file
64
src/ds/NameEdit.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
|
||||
import { MercuryButton } from "./MercuryButton";
|
||||
import { t } from "@/i18n/common";
|
||||
|
||||
export interface NameEditProps {
|
||||
initial: string;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
onSave: (name: string) => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost-input edit form for renaming things (mirrors iOS `NameEditView`):
|
||||
* a centered title, a large text field pre-filled with the current value,
|
||||
* and save/cancel actions. Used instead of a native prompt/alert per
|
||||
* Mercury's UI conventions.
|
||||
*/
|
||||
export function NameEdit({ initial, title, placeholder = "Нэр", onSave, onCancel }: NameEditProps) {
|
||||
const [name, setName] = useState(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const trimmed = name.trim();
|
||||
const canSave = trimmed.length > 0 && !saving;
|
||||
|
||||
async function handleSave() {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(trimmed);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
<h2 style={{ textAlign: "center", margin: 0 }}>{title}</h2>
|
||||
<TextFieldRoot value={name} onValueChange={setName}>
|
||||
<TextFieldInput
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
style={{ textAlign: "center", fontSize: 24 }}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
<MercuryButton variant="secondary" onClick={onCancel} style={{ flex: 1 }}>
|
||||
{t.common.cancel}
|
||||
</MercuryButton>
|
||||
<MercuryButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{t.common.save}
|
||||
</MercuryButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/ds/TabBar.tsx
Normal file
92
src/ds/TabBar.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@seed-design/react";
|
||||
import { t } from "@/i18n/common";
|
||||
|
||||
export type TabKey = "home" | "accounting" | "planner" | "assets" | "profile";
|
||||
|
||||
const HREF: Record<TabKey, string> = {
|
||||
home: "/home",
|
||||
accounting: "/accounting",
|
||||
planner: "/planner",
|
||||
assets: "/assets",
|
||||
profile: "/profile",
|
||||
};
|
||||
|
||||
// `@seed-design/react`'s `Icon` takes a raw `svg` node rather than a named
|
||||
// icon catalog (there's no bundled icon-name set in the installed
|
||||
// @seed-design/react/css versions — verified under node_modules), so these
|
||||
// are small hand-drawn stand-ins mirroring the iOS tab glyphs
|
||||
// (houseFill / horizline3VerticalFill / checkmarkCalendarFill / cardFill /
|
||||
// personFill from HomeTabBar.swift). Swap for the real Seed icon set once
|
||||
// it's available in this app.
|
||||
// Seed's `<Icon svg={...} />` uses a Radix `Slot` internally, which clones
|
||||
// its size/color props onto a *single* element — so each entry here must be
|
||||
// one root `<svg>` node, not a fragment of bare paths.
|
||||
function tabSvg(children: React.ReactNode): React.ReactNode {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB_ICON: Record<TabKey, React.ReactNode> = {
|
||||
home: tabSvg(<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />),
|
||||
accounting: tabSvg(
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="3" rx="1" />
|
||||
<rect x="3" y="10.5" width="18" height="3" rx="1" />
|
||||
<rect x="3" y="16" width="18" height="3" rx="1" />
|
||||
</>,
|
||||
),
|
||||
planner: tabSvg(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="m8 14 2.5 2.5L16 11" />
|
||||
</>,
|
||||
),
|
||||
assets: tabSvg(<rect x="2.5" y="5" width="19" height="14" rx="2" />),
|
||||
profile: tabSvg(
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
|
||||
</>,
|
||||
),
|
||||
};
|
||||
|
||||
const TAB_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
|
||||
|
||||
export interface TabBarProps {
|
||||
active: TabKey;
|
||||
}
|
||||
|
||||
/** Bottom rounded nav bar on mobile (< md), left sidebar on desktop (>= md).
|
||||
* Ports `HomeTabBar.swift`: active item uses primary text color, the rest
|
||||
* use the placeholder/muted color. */
|
||||
export function TabBar({ active }: TabBarProps) {
|
||||
return (
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 flex items-stretch justify-around rounded-t-2xl px-4 pt-2 pb-2 shadow-[0_-2px_5px_rgba(0,0,0,0.1)] md:static md:h-screen md:w-56 md:flex-col md:items-stretch md:justify-start md:gap-1 md:rounded-none md:px-3 md:py-6 md:shadow-none"
|
||||
style={{ background: "var(--seed-color-bg-layer-default)" }}
|
||||
>
|
||||
{TAB_ORDER.map((key) => {
|
||||
const isActive = key === active;
|
||||
return (
|
||||
<Link
|
||||
key={key}
|
||||
href={HREF[key]}
|
||||
className="flex flex-1 flex-col items-center gap-1 p-1 md:flex-none md:flex-row md:justify-start md:gap-3 md:rounded-xl md:px-3 md:py-2"
|
||||
style={{ color: isActive ? "var(--seed-color-fg-neutral)" : "var(--seed-color-fg-placeholder)" }}
|
||||
>
|
||||
<Icon svg={TAB_ICON[key]} size="24px" />
|
||||
<span className="text-xs md:text-sm">{t.tabs[key]}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
17
src/ds/index.ts
Normal file
17
src/ds/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export { MercuryButton } from "./MercuryButton";
|
||||
export type { MercuryButtonProps, MercuryButtonVariant } from "./MercuryButton";
|
||||
|
||||
export { AmountToggle } from "./AmountToggle";
|
||||
export type { AmountToggleProps } from "./AmountToggle";
|
||||
|
||||
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
|
||||
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
|
||||
|
||||
export { TabBar } from "./TabBar";
|
||||
export type { TabBarProps, TabKey } from "./TabBar";
|
||||
|
||||
export { NameEdit } from "./NameEdit";
|
||||
export type { NameEditProps } from "./NameEdit";
|
||||
|
||||
export { Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
15
src/i18n/common.ts
Normal file
15
src/i18n/common.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export const t = {
|
||||
tabs: {
|
||||
home: "Нүүр",
|
||||
accounting: "Тооцоо",
|
||||
planner: "Төсөв",
|
||||
assets: "Хөрөнгө",
|
||||
profile: "Миний",
|
||||
},
|
||||
common: {
|
||||
save: "Хадгалах",
|
||||
cancel: "Болих",
|
||||
connect: "Холбох",
|
||||
delete: "Устгах",
|
||||
},
|
||||
} as const;
|
||||
Loading…
Add table
Reference in a new issue