Skip to content
edmi-ui

Join Panel

Amount input with Max and currency, summary rows and a primary join action.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
USDC
Estimated shares982.09
Fee1.00%
USDC
Estimated shares982.09
Fee1.00%
USDC
Estimated shares 982.09
Fee 1.00%
Terminal window
npx shadcn@latest add @edmi-ui/join-panel
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/join-panel
Terminal window
yarn dlx shadcn@latest add @edmi-ui/join-panel
Terminal window
bunx shadcn@latest add @edmi-ui/join-panel

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/join-panel
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/join-panel
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/join-panel
Terminal window
bunx shadcn-vue@latest add @edmi-ui/join-panel

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/join-panel.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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}
/>
registry/blocks/join-panel/join-panel.tsx
"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 };
registry/ui/join-panel/JoinPanel.vue
<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>
registry/ui/join-panel/index.ts
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";
src/lib/registry/ui/join-panel/index.ts
export {
default as JoinPanel,
type JoinPanelQuickAmount,
type JoinPanelRow,
type JoinPanelTab,
} from "./join-panel.svelte";
src/lib/registry/ui/join-panel/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>

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.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

USDC
Estimated shares982.09
Fee1.00%

Raised (+1)

USDC
Estimated shares982.09
Fee1.00%

Floating (+2)

USDC
Estimated shares982.09
Fee1.00%

Flat (0)

USDC
Estimated shares982.09
Fee1.00%

Raised (+1)

USDC
Estimated shares982.09
Fee1.00%

Floating (+2)

USDC
Estimated shares982.09
Fee1.00%

Flat (0)

USDC
Estimated shares 982.09
Fee 1.00%

Raised (+1)

USDC
Estimated shares 982.09
Fee 1.00%

Floating (+2)

USDC
Estimated shares 982.09
Fee 1.00%
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

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.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Max 1,240
Estimated shares99.86
Entry fee0%
Self-custodied · Redeem anytime
Max 1,240
Estimated shares99.86
Entry fee0%
Self-custodied · Redeem anytime
Max 1,240
Estimated shares 99.86
Entry fee 0%
Self-custodied · Redeem anytime
  • Estimates are the caller’s job: compute rows from amount.