Join Panel
Amount input with Max and currency, summary rows and a primary join action.
import { JoinPanel } from "@/components/join-panel/join-panel";import { useState } from "react";
export default function Demo() { const [amount, setAmount] = useState("1,000"); const n = Number(amount.replace(/,/g, "")) || 0; return ( <JoinPanel amount={amount} onAmountChange={setAmount} onMax={() => setAmount("2,500")} label="Amount" currency="USDC" rows={[ { label: "Estimated shares", value: (n * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" }, ]} joinLabel="Join MAG4" disabled={n === 0} /> );}<script setup lang="ts">import { computed, ref } from "vue";import { JoinPanel } from "@/components/ui/join-panel";
const amount = ref("1,000");const n = computed(() => Number(amount.value.replace(/,/g, "")) || 0);const rows = computed(() => [ { label: "Estimated shares", value: (n.value * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" },]);</script>
<template> <JoinPanel v-model:amount="amount" label="Amount" currency="USDC" :rows="rows" join-label="Join MAG4" :disabled="n === 0" @max="amount = '2,500'" /></template><script lang="ts"> import { JoinPanel } from "$lib/components/ui/join-panel";
let amount = $state("1,000"); const n = $derived(Number(amount.replace(/,/g, "")) || 0); const rows = $derived([ { label: "Estimated shares", value: (n * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" }, ]);</script>
<JoinPanel bind:amount onMax={() => (amount = "2,500")} label="Amount" currency="USDC" {rows} joinLabel="Join MAG4" disabled={n === 0}/>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/join-panelpnpm dlx shadcn@latest add @edmi-ui/join-panelyarn dlx shadcn@latest add @edmi-ui/join-panelbunx shadcn@latest add @edmi-ui/join-panelPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
card,button,input-group,tabs,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/join-panelpnpm dlx shadcn-vue@latest add @edmi-ui/join-panelyarn dlx shadcn-vue@latest add @edmi-ui/join-panelbunx shadcn-vue@latest add @edmi-ui/join-panelPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
card,button,input-group,tabs,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
card,button,input-group,tabs,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { JoinPanel } from "@/components/join-panel/join-panel";
<JoinPanel label="Amount" currency="USDC" rows={[ { label: "Estimated shares", value: (n * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" }, ]} joinLabel="Join MAG4" disabled={n === 0}/><script setup lang="ts">import { JoinPanel } from "@/components/ui/join-panel";</script>
<template> <JoinPanel label="Amount" currency="USDC" join-label="Join MAG4" :disabled="n === 0" /></template><script lang="ts"> import { JoinPanel } from "$lib/components/ui/join-panel";</script>
<JoinPanel bind:amount label="Amount" currency="USDC" joinLabel="Join MAG4" disabled={n === 0}/>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import * as React from "react";import { Button } from "@/registry/edmi/ui/button";import { Card } from "@/registry/edmi/ui/card";import { controlLevel, type Elevation } from "@/registry/edmi/ui/elevation";import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText,} from "@/registry/edmi/ui/input-group";import { Tabs, TabsList, TabsTrigger } from "@/registry/edmi/ui/tabs";
type JoinPanelRow = { label: React.ReactNode; value: React.ReactNode };/** ✦ Quick-amount chip: sets the amount to `value`; without `value` it calls `onMax`. */type JoinPanelQuickAmount = { label: React.ReactNode; value?: string };/** ✦ Mode tab, e.g. Join / Redeem. */type JoinPanelTab = { value: string; label: React.ReactNode };
// Amount field (mono, Max button, currency) + summary rows + one big action.function JoinPanel({ className, label = "Amount", currency, amount, defaultAmount = "", onAmountChange, onMax, rows = [], tabs, tab, defaultTab, onTabChange, quickAmounts, footnote, amountSize = "default", maxLabel, joinLabel = "Join", onJoin, disabled, elevation, ...props}: Omit<React.ComponentProps<typeof Card>, "children" | "size" | "onSubmit"> & { label?: React.ReactNode; /** Unit shown in the label and beside the field (e.g. `USDC`); omit for none. */ currency?: string; /** Controlled value. */ amount?: string; defaultAmount?: string; onAmountChange?: (value: string) => void; onMax?: () => void; /** Summary rows (estimated shares, fee, …). Values render mono. */ rows?: JoinPanelRow[]; /** ✦ Join / Redeem style mode tabs above the amount (the IndexDetail board). */ tabs?: JoinPanelTab[]; /** ✦ Controlled tab value. */ tab?: string; defaultTab?: string; onTabChange?: (value: string) => void; /** ✦ Chips under the amount field, e.g. `[{ label: "$10", value: "10" }, { label: "Max" }]`. */ quickAmounts?: JoinPanelQuickAmount[]; /** ✦ Small centred note under the action, e.g. `Self-custodied · Redeem anytime`. */ footnote?: React.ReactNode; /** ✦ `lg`: taller field with a 26px mono amount (the IndexDetail board). */ amountSize?: "default" | "lg"; /** ✦ Plain muted text in the field (e.g. `Max 1,240`) instead of the Max button and currency. */ maxLabel?: React.ReactNode; joinLabel?: React.ReactNode; onJoin?: () => void; disabled?: boolean; /** ✦ depth of the card; raised +1 / floating +2 also raise the tabs, chips and join Button. */ elevation?: Elevation;}) { const control = controlLevel(elevation); const id = React.useId(); const [inner, setInner] = React.useState(defaultAmount); const value = amount ?? inner; const [innerTab, setInnerTab] = React.useState( defaultTab ?? tabs?.[0]?.value ?? "", ); // Digits, thousands separators and a decimal point only. const setAmount = (raw: string) => { const v = raw.replace(/[^\d.,]/g, ""); setInner(v); onAmountChange?.(v); }; return ( <Card data-slot="join-panel" size="sm" elevation={elevation} className={cn("w-80 gap-0", className)} {...props} > {tabs?.length ? ( <div className="mb-5 px-(--card-spacing)"> <Tabs value={tab ?? innerTab} onValueChange={(v) => { setInnerTab(v as string); onTabChange?.(v as string); }} > <TabsList elevation={control} className="w-full"> {tabs.map((t) => ( <TabsTrigger key={t.value} value={t.value}> {t.label} </TabsTrigger> ))} </TabsList> </Tabs> </div> ) : null} <label htmlFor={id} className="px-(--card-spacing) text-xs text-muted-foreground" > {label} {currency ? ` (${currency})` : null} </label> <div className="px-(--card-spacing)"> <InputGroup className={cn("mt-2", amountSize === "lg" ? "h-14" : "h-11")} > <InputGroupInput id={id} inputMode="decimal" autoComplete="off" value={value} onChange={(e) => setAmount(e.target.value)} className={cn( "font-mono", amountSize === "lg" ? "text-[26px]" : "text-[17px]", )} /> <InputGroupAddon align="inline-end"> {maxLabel ? ( <InputGroupText className="bg-transparent text-xs"> {maxLabel} </InputGroupText> ) : ( <> <InputGroupButton variant="secondary" onClick={onMax}> Max </InputGroupButton> {currency ? ( <InputGroupText className="bg-transparent px-1.5 text-xs"> {currency} </InputGroupText> ) : null} </> )} </InputGroupAddon> </InputGroup> </div> {quickAmounts?.length ? ( <div className="mt-3 flex gap-2 px-(--card-spacing)"> {quickAmounts.map((q, i) => ( <Button // biome-ignore lint/suspicious/noArrayIndexKey: static chips key={i} type="button" variant="secondary" size="sm" elevation={control} className="flex-1 font-mono" onClick={() => q.value !== undefined ? setAmount(q.value) : onMax?.() } > {q.label} </Button> ))} </div> ) : null} {rows.map((r, i) => ( <div // biome-ignore lint/suspicious/noArrayIndexKey: static summary rows key={i} className={cn( "flex items-center justify-between px-(--card-spacing) text-[13px]", i === 0 ? "mt-3" : "mt-1.5", )} > <span className="text-muted-foreground">{r.label}</span> <span className="font-mono">{r.value}</span> </div> ))} <div className="mt-3.5 px-(--card-spacing)"> <Button size="lg" elevation={control} className="w-full" onClick={onJoin} disabled={disabled} > {joinLabel} </Button> </div> {footnote ? ( <div className="mt-3 px-(--card-spacing) text-center text-xs text-muted-foreground-2"> {footnote} </div> ) : null} </Card> );}
export type { JoinPanelQuickAmount, JoinPanelRow, JoinPanelTab };export { JoinPanel };<script setup lang="ts">import type { HTMLAttributes } from 'vue'import type { JoinPanelQuickAmount, JoinPanelRow, JoinPanelTab } from '.'import { computed, ref, useId } from 'vue'import { cn } from '@/registry/edmi/lib/utils'import { Button } from '@/registry/edmi/ui/button'import { Card } from '@/registry/edmi/ui/card'import { controlLevel, type Elevation } from '@/registry/edmi/ui/elevation'import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText,} from '@/registry/edmi/ui/input-group'import { Tabs, TabsList, TabsTrigger } from '@/registry/edmi/ui/tabs'
const props = withDefaults(defineProps<{ /** ✦ depth of the card; raised +1 / floating +2 also raise the tabs, chips and join Button */ elevation?: Elevation /** Field label (or use the `label` slot). */ label?: string /** Unit shown in the label and beside the field (e.g. `USDC`); omit for none. */ currency?: string defaultAmount?: string /** Summary rows (estimated shares, fee, …). Values render mono. */ rows?: JoinPanelRow[] /** ✦ Join / Redeem style mode tabs above the amount (the IndexDetail board). */ tabs?: JoinPanelTab[] defaultTab?: string /** ✦ Chips under the amount field, e.g. `[{ label: '$10', value: '10' }, { label: 'Max' }]`. */ quickAmounts?: JoinPanelQuickAmount[] /** ✦ Small centred note under the action (or use the `footnote` slot). */ footnote?: string /** ✦ `lg`: taller field with a 26px mono amount (the IndexDetail board). */ amountSize?: 'default' | 'lg' /** ✦ Plain muted text in the field (e.g. `Max 1,240`; or the `max-label` slot) instead of the Max button and currency. */ maxLabel?: string /** Action label (or use the `join-label` slot). */ joinLabel?: string disabled?: boolean class?: HTMLAttributes['class']}>(), { label: 'Amount', amountSize: 'default', defaultAmount: '', rows: () => [], joinLabel: 'Join', elevation: undefined,})
// Controls follow an explicit card level: raised/floating raise them, flat/sunken keep them flat; auto leaves them to their own role.const control = computed(() => controlLevel(props.elevation))
const emit = defineEmits<{ (e: 'max'): void (e: 'join'): void}>()
// ✦ `v-model:tab` = controlled; without it the panel keeps its own tab.const tab = defineModel<string>('tab')const innerTab = ref(props.defaultTab ?? props.tabs?.[0]?.value ?? '')const tabValue = computed({ get: () => tab.value ?? innerTab.value, set: (v: string) => { innerTab.value = v tab.value = v },})
// `v-model:amount` = controlled; without it the panel keeps its own value.const amount = defineModel<string>('amount')const value = computed(() => amount.value ?? props.defaultAmount)const id = useId()
// Digits, thousands separators and a decimal point only.function sanitize(v: string) { return v.replace(/[^\d.,]/g, '')}function onInput(e: Event) { const el = e.target as HTMLInputElement const clean = sanitize(el.value) if (clean !== el.value) el.value = clean}</script>
<template> <!-- Amount field (mono, Max button, currency) + summary rows + one big action. --> <Card :elevation="elevation" data-slot="join-panel" size="sm" :class="cn('w-80 gap-0', props.class)"> <div v-if="tabs?.length" class="mb-5 px-(--card-spacing)"> <Tabs v-model="tabValue"> <TabsList :elevation="control" class="w-full"> <TabsTrigger v-for="t in tabs" :key="t.value" :value="t.value"> {{ t.label }} </TabsTrigger> </TabsList> </Tabs> </div> <label :for="id" class="px-(--card-spacing) text-xs text-muted-foreground"> <slot name="label">{{ label }}</slot>{{ currency ? ` (${currency})` : '' }} </label> <div class="px-(--card-spacing)"> <InputGroup :class="cn('mt-2', amountSize === 'lg' ? 'h-14' : 'h-11')"> <InputGroupInput :id="id" inputmode="decimal" autocomplete="off" :model-value="value" :class="cn('font-mono', amountSize === 'lg' ? 'text-[26px]' : 'text-[17px]')" @input="onInput" @update:model-value="(v: string | number) => (amount = sanitize(String(v)))" /> <InputGroupAddon align="inline-end"> <InputGroupText v-if="maxLabel || $slots['max-label']" class="bg-transparent text-xs"> <slot name="max-label">{{ maxLabel }}</slot> </InputGroupText> <template v-else> <InputGroupButton variant="secondary" @click="emit('max')"> Max </InputGroupButton> <InputGroupText v-if="currency" class="bg-transparent px-1.5 text-xs"> {{ currency }} </InputGroupText> </template> </InputGroupAddon> </InputGroup> </div> <div v-if="quickAmounts?.length" class="mt-3 flex gap-2 px-(--card-spacing)"> <Button v-for="(q, i) in quickAmounts" :key="i" type="button" variant="secondary" size="sm" :elevation="control" class="flex-1 font-mono" @click="q.value !== undefined ? (amount = q.value) : emit('max')" > {{ q.label }} </Button> </div> <div v-for="(r, i) in rows" :key="i" :class="cn('flex items-center justify-between px-(--card-spacing) text-[13px]', i === 0 ? 'mt-3' : 'mt-1.5')" > <span class="text-muted-foreground"><slot name="row-label" :row="r" :index="i">{{ r.label }}</slot></span> <span class="font-mono"><slot name="row-value" :row="r" :index="i">{{ r.value }}</slot></span> </div> <div class="mt-3.5 px-(--card-spacing)"> <Button size="lg" class="w-full" :elevation="control" :disabled="disabled" @click="emit('join')"> <slot name="join-label">{{ joinLabel }}</slot> </Button> </div> <div v-if="footnote || $slots.footnote" class="mt-3 px-(--card-spacing) text-center text-xs text-muted-foreground-2"> <slot name="footnote">{{ footnote }}</slot> </div> </Card></template>export type JoinPanelRow = { label: string; value: string | number };/** ✦ Quick-amount chip: sets the amount to `value`; without `value` it emits `max`. */export type JoinPanelQuickAmount = { label: string; value?: string };/** ✦ Mode tab, e.g. Join / Redeem. */export type JoinPanelTab = { value: string; label: string };
export { default as JoinPanel } from "./JoinPanel.vue";export { default as JoinPanel, type JoinPanelQuickAmount, type JoinPanelRow, type JoinPanelTab,} from "./join-panel.svelte";<script lang="ts" module> import type { Snippet } from "svelte";
export type JoinPanelRow = { label: string | Snippet; value: string | number | Snippet }; /** ✦ Quick-amount chip: sets the amount to `value`; without `value` it calls `onMax`. */ export type JoinPanelQuickAmount = { label: string | Snippet; value?: string }; /** ✦ Mode tab, e.g. Join / Redeem. */ export type JoinPanelTab = { value: string; label: string | Snippet };</script>
<script lang="ts"> import { Button } from "$lib/registry/ui/button/index.js"; import { Card } from "$lib/registry/ui/card/index.js"; import { controlLevel, type Elevation } from "$lib/registry/ui/elevation/index.js"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, } from "$lib/registry/ui/input-group/index.js"; import { Tabs, TabsList, TabsTrigger } from "$lib/registry/ui/tabs/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), class: className, label = "Amount", currency, amount = $bindable(""), onAmountChange, onMax, rows = [], tabs, tab = $bindable(), onTabChange, quickAmounts, footnote, amountSize = "default", maxLabel, joinLabel = "Join", onJoin, disabled, elevation = "auto", ...restProps }: Omit<ComponentProps<typeof Card>, "children" | "size"> & { label?: string | Snippet; /** Unit shown in the label and beside the field (e.g. `USDC`); omit for none. */ currency?: string; /** Two-way bindable (`bind:amount`). */ amount?: string; onAmountChange?: (value: string) => void; onMax?: () => void; /** Summary rows (estimated shares, fee, …). Values render mono. */ rows?: JoinPanelRow[]; /** ✦ Join / Redeem style mode tabs above the amount (the IndexDetail board). */ tabs?: JoinPanelTab[]; /** ✦ Two-way bindable tab value (`bind:tab`); defaults to the first tab. */ tab?: string; onTabChange?: (value: string) => void; /** ✦ Chips under the amount field, e.g. `[{ label: "$10", value: "10" }, { label: "Max" }]`. */ quickAmounts?: JoinPanelQuickAmount[]; /** ✦ Small centred note under the action. */ footnote?: string | Snippet; /** ✦ `lg`: taller field with a 26px mono amount (the IndexDetail board). */ amountSize?: "default" | "lg"; /** ✦ Plain muted text in the field (e.g. `Max 1,240`) instead of the Max button and currency. */ maxLabel?: string | Snippet; joinLabel?: string | Snippet; onJoin?: () => void; disabled?: boolean; /** ✦ depth of the card; raised +1 / floating +2 also raise the tabs, chips and Join button. */ elevation?: Elevation; } = $props();
// Controls follow an explicit card level (flat/sunken keep them flat); auto leaves them to their own role. const control = $derived<Elevation>(controlLevel(elevation) ?? "auto");
const id = $props.id();
</script>
<!-- Amount field (mono, Max button, currency) + summary rows + one big action. --><Card bind:ref data-slot="join-panel" size="sm" {elevation} class={cn("w-80 gap-0", className)} {...restProps}> {#if tabs?.length} <div class="mb-5 px-(--card-spacing)"> <Tabs value={tab ?? tabs[0].value} onValueChange={(v) => { tab = v; onTabChange?.(v); }} > <TabsList elevation={control} class="w-full"> {#each tabs as t (t.value)} <TabsTrigger value={t.value}> {#if typeof t.label === "function"}{@render t.label()}{:else}{t.label}{/if} </TabsTrigger> {/each} </TabsList> </Tabs> </div> {/if} <label for={id} class="px-(--card-spacing) text-xs text-muted-foreground"> {#if typeof label === "function"}{@render label()}{:else}{label}{/if}{currency ? ` (${currency})` : ""} </label> <div class="px-(--card-spacing)"> <InputGroup class={cn("mt-2", amountSize === "lg" ? "h-14" : "h-11")}> <InputGroupInput {id} inputmode="decimal" autocomplete="off" bind:value={amount} oninput={() => { // Digits, thousands separators and a decimal point only. amount = amount.replace(/[^\d.,]/g, ""); onAmountChange?.(amount); }} class={cn("font-mono", amountSize === "lg" ? "text-[26px]" : "text-[17px]")} /> <InputGroupAddon align="inline-end"> {#if maxLabel} <InputGroupText class="bg-transparent text-xs"> {#if typeof maxLabel === "function"}{@render maxLabel()}{:else}{maxLabel}{/if} </InputGroupText> {:else} <InputGroupButton variant="secondary" onclick={onMax}>Max</InputGroupButton> {#if currency}<InputGroupText class="bg-transparent px-1.5 text-xs">{currency}</InputGroupText>{/if} {/if} </InputGroupAddon> </InputGroup> </div> {#if quickAmounts?.length} <div class="mt-3 flex gap-2 px-(--card-spacing)"> {#each quickAmounts as q, i (i)} <Button type="button" variant="secondary" size="sm" elevation={control} class="flex-1 font-mono" onclick={() => { if (q.value !== undefined) { amount = q.value; onAmountChange?.(q.value); } else onMax?.(); }} > {#if typeof q.label === "function"}{@render q.label()}{:else}{q.label}{/if} </Button> {/each} </div> {/if} {#each rows as r, i (i)} <div class={cn("flex items-center justify-between px-(--card-spacing) text-[13px]", i === 0 ? "mt-3" : "mt-1.5")}> <span class="text-muted-foreground"> {#if typeof r.label === "function"}{@render r.label()}{:else}{r.label}{/if} </span> <span class="font-mono"> {#if typeof r.value === "function"}{@render r.value()}{:else}{r.value}{/if} </span> </div> {/each} <div class="mt-3.5 px-(--card-spacing)"> <Button size="lg" elevation={control} class="w-full" onclick={onJoin} {disabled}> {#if typeof joinLabel === "function"}{@render joinLabel()}{:else}{joinLabel}{/if} </Button> </div> {#if footnote} <div class="mt-3 px-(--card-spacing) text-center text-xs text-muted-foreground-2"> {#if typeof footnote === "function"}{@render footnote()}{:else}{footnote}{/if} </div> {/if}</Card>Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
Flat by default. elevation is forwarded to the card (sunken -1, raised +1, floating +2); an explicit raised or floating also raises the tabs, quick-amount chips and the join button, flat and sunken keep them flat.
Flat (0)
Raised (+1)
Floating (+2)
Flat (0)
Raised (+1)
Floating (+2)
Flat (0)
Raised (+1)
Floating (+2)
import { JoinPanel } from "@/components/join-panel/join-panel";import type { Elevation } from "@/components/ui/elevation";import { useState } from "react";
function Sample({ elevation }: { elevation: Elevation }) { const [amount, setAmount] = useState("1,000"); const n = Number(amount.replace(/,/g, "")) || 0; return ( <JoinPanel elevation={elevation} amount={amount} onAmountChange={setAmount} onMax={() => setAmount("2,500")} label="Amount" currency="USDC" rows={[ { label: "Estimated shares", value: (n * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" }, ]} joinLabel="Join MAG4" disabled={n === 0} /> );}
const levels = [ { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex flex-col gap-6"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <Sample elevation={value} /> </div> ))} </div> );}<script setup lang="ts">import { computed, ref } from "vue";import { JoinPanel } from "@/components/ui/join-panel";
const amount = ref("1,000");const n = computed(() => Number(amount.value.replace(/,/g, "")) || 0);const rows = computed(() => [ { label: "Estimated shares", value: (n.value * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" },]);
const levels = [ { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, { value: "floating", label: "Floating (+2)" } as const,];</script>
<template> <div class="flex flex-col gap-6"> <div v-for="l in levels" :key="l.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ l.label }}</p> <JoinPanel :elevation="l.value" v-model:amount="amount" label="Amount" currency="USDC" :rows="rows" join-label="Join MAG4" :disabled="n === 0" @max="amount = '2,500'" /> </div> </div></template><script lang="ts"> import { JoinPanel } from "$lib/components/ui/join-panel";
let amount = $state("1,000"); const n = $derived(Number(amount.replace(/,/g, "")) || 0); const rows = $derived([ { label: "Estimated shares", value: (n * 0.98209).toFixed(2) }, { label: "Fee", value: "1.00%" }, ]);
const levels = [ { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, { value: "floating", label: "Floating (+2)" } as const, ];</script>
<div class="flex flex-col gap-6"> {#each levels as l (l.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{l.label}</p><JoinPanel elevation={l.value} bind:amount onMax={() => (amount = "2,500")} label="Amount" currency="USDC" {rows} joinLabel="Join MAG4" disabled={n === 0}/> </div> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
JoinPanel |
label |
ReactNode (Vue label slot, Svelte string | Snippet) |
"Amount" |
currency |
string, shown in the label and beside the field |
none | |
amount / defaultAmount |
string (Vue v-model:amount, Svelte bind:amount) |
"" |
|
onAmountChange |
(value: string) => void |
none | |
onMax / onJoin |
() => void (Vue emits max / join) |
none | |
rows |
{ label, value }[], values mono (Vue row-label / row-value slots, Svelte string | Snippet) |
[] |
|
joinLabel |
ReactNode (Vue join-label slot, Svelte string | Snippet) |
"Join" |
|
disabled |
boolean |
false |
|
elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
|
tabs ✦ |
{ value, label }[] mode tabs (Join / Redeem) |
none | |
tab / defaultTab ✦, onTabChange |
controlled tab (Vue v-model:tab, Svelte bind:tab) |
first tab | |
quickAmounts ✦ |
{ label, value? }[] chips; no value calls onMax |
none | |
footnote ✦ |
ReactNode small centred note under the action (Vue footnote slot, Svelte string | Snippet) |
none | |
amountSize ✦ |
"default" | "lg" |
"default" |
|
maxLabel ✦ |
plain Max 1,240 text instead of the Max button (Vue max-label slot, Svelte string | Snippet) |
none |
Tabs, chips and footnote ✦
Section titled “Tabs, chips and footnote ✦”The IndexDetail board panel: Join / Redeem tabs, quick-amount chips, a large amount field and a footnote. Vue uses v-model:tab, Svelte bind:tab.
import { JoinPanel } from "@/components/join-panel/join-panel";import { useState } from "react";
const MAX = 1240;
export default function Demo() { const [tab, setTab] = useState("join"); const [amount, setAmount] = useState("100"); const n = Number(amount.replace(/,/g, "")) || 0; return ( <JoinPanel tabs={[ { value: "join", label: "Join" }, { value: "redeem", label: "Redeem" }, ]} tab={tab} onTabChange={setTab} label={tab === "join" ? "Amount" : "Shares"} currency={tab === "join" ? "USDC" : "MAG4"} amount={amount} onAmountChange={setAmount} onMax={() => setAmount(MAX.toLocaleString("en-US"))} amountSize="lg" maxLabel="Max 1,240" quickAmounts={[ { label: "$10", value: "10" }, { label: "$50", value: "50" }, { label: "$100", value: "100" }, { label: "Max" }, ]} rows={[ { label: tab === "join" ? "Estimated shares" : "Estimated payout", value: (n * 0.9986).toFixed(2), }, { label: tab === "join" ? "Entry fee" : "Exit fee", value: "0%" }, ]} joinLabel={`${tab === "join" ? "Join" : "Redeem"} with ${amount || 0} ${tab === "join" ? "USDC" : "MAG4"}`} disabled={n === 0} footnote="Self-custodied · Redeem anytime" className="w-[380px]" /> );}<script setup lang="ts">import { computed, ref } from "vue";import { JoinPanel } from "@/components/ui/join-panel";
const MAX = 1240;const tab = ref("join");const amount = ref("100");const n = computed(() => Number(amount.value.replace(/,/g, "")) || 0);const tabs = [ { value: "join", label: "Join" }, { value: "redeem", label: "Redeem" },];const quickAmounts = [ { label: "$10", value: "10" }, { label: "$50", value: "50" }, { label: "$100", value: "100" }, { label: "Max" },];const isJoin = computed(() => tab.value === "join");const rows = computed(() => [ { label: isJoin.value ? "Estimated shares" : "Estimated payout", value: (n.value * 0.9986).toFixed(2), }, { label: isJoin.value ? "Entry fee" : "Exit fee", value: "0%" },]);</script>
<template> <JoinPanel v-model:tab="tab" v-model:amount="amount" :tabs="tabs" :label="isJoin ? 'Amount' : 'Shares'" :currency="isJoin ? 'USDC' : 'MAG4'" amount-size="lg" max-label="Max 1,240" :quick-amounts="quickAmounts" :rows="rows" :join-label="`${isJoin ? 'Join' : 'Redeem'} with ${amount || 0} ${isJoin ? 'USDC' : 'MAG4'}`" :disabled="n === 0" footnote="Self-custodied · Redeem anytime" class="w-[380px]" @max="amount = MAX.toLocaleString('en-US')" /></template><script lang="ts"> import { JoinPanel } from "$lib/components/ui/join-panel";
const MAX = 1240; let tab = $state("join"); let amount = $state("100"); const n = $derived(Number(amount.replace(/,/g, "")) || 0); const isJoin = $derived(tab === "join"); const tabs = [ { value: "join", label: "Join" }, { value: "redeem", label: "Redeem" }, ]; const quickAmounts = [ { label: "$10", value: "10" }, { label: "$50", value: "50" }, { label: "$100", value: "100" }, { label: "Max" }, ]; const rows = $derived([ { label: isJoin ? "Estimated shares" : "Estimated payout", value: (n * 0.9986).toFixed(2), }, { label: isJoin ? "Entry fee" : "Exit fee", value: "0%" }, ]);</script>
<JoinPanel {tabs} bind:tab bind:amount onMax={() => (amount = MAX.toLocaleString("en-US"))} label={isJoin ? "Amount" : "Shares"} currency={isJoin ? "USDC" : "MAG4"} amountSize="lg" maxLabel="Max 1,240" {quickAmounts} {rows} joinLabel="{isJoin ? 'Join' : 'Redeem'} with {amount || 0} {isJoin ? 'USDC' : 'MAG4'}" disabled={n === 0} footnote="Self-custodied · Redeem anytime" class="w-[380px]"/>✦ Edmi additions
Section titled “✦ Edmi additions”- Estimates are the caller’s job: compute
rowsfromamount.