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