feat(miniapp): refine UI and add utility bill management

- Fix collapsible padding and button spacing
- Add subtotal to balance card
- Add utility bill management for admins
- Fix lints and type checks across the monorepo
- Implement rejectPendingHouseholdMember in repository and service
This commit is contained in:
2026-03-13 05:52:34 +04:00
parent 25c4928ca9
commit 94a5904f54
58 changed files with 5400 additions and 7006 deletions

View File

@@ -1,53 +0,0 @@
import { For } from 'solid-js'
import type { MiniAppDashboard } from '../../miniapp-api'
import { StatCard } from '../ui'
type SummaryItem = {
label: string
value: string
}
type Props = {
dashboard: MiniAppDashboard
utilityTotalMajor: string
purchaseTotalMajor: string
labels: {
remaining: string
rent: string
utilities: string
purchases: string
}
}
export function FinanceSummaryCards(props: Props) {
const items: SummaryItem[] = [
{
label: props.labels.remaining,
value: `${props.dashboard.totalRemainingMajor} ${props.dashboard.currency}`
},
{
label: props.labels.rent,
value: `${props.dashboard.rentDisplayAmountMajor} ${props.dashboard.currency}`
},
{
label: props.labels.utilities,
value: `${props.utilityTotalMajor} ${props.dashboard.currency}`
},
{
label: props.labels.purchases,
value: `${props.purchaseTotalMajor} ${props.dashboard.currency}`
}
]
return (
<For each={items}>
{(item) => (
<StatCard>
<span>{item.label}</span>
<strong>{item.value}</strong>
</StatCard>
)}
</For>
)
}

View File

@@ -1,161 +0,0 @@
import { For, Match, Switch } from 'solid-js'
import type { MiniAppDashboard } from '../../miniapp-api'
type MemberVisual = {
member: MiniAppDashboard['members'][number]
totalMinor: bigint
barWidthPercent: number
segments: {
key: string
label: string
amountMajor: string
amountMinor: bigint
widthPercent: number
}[]
}
type PurchaseSlice = {
key: string
label: string
amountMajor: string
color: string
percentage: number
dasharray: string
dashoffset: string
}
type Props = {
dashboard: MiniAppDashboard
memberVisuals: readonly MemberVisual[]
purchaseChart: {
totalMajor: string
slices: readonly PurchaseSlice[]
}
labels: {
financeVisualsTitle: string
financeVisualsBody: string
membersCount: string
purchaseInvestmentsTitle: string
purchaseInvestmentsBody: string
purchaseInvestmentsEmpty: string
purchaseTotalLabel: string
purchaseShareLabel: string
}
remainingClass: (member: MiniAppDashboard['members'][number]) => string
}
export function FinanceVisuals(props: Props) {
return (
<>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.labels.financeVisualsTitle}</strong>
<span>
{props.labels.membersCount}: {String(props.dashboard.members.length)}
</span>
</header>
<p>{props.labels.financeVisualsBody}</p>
<div class="member-visual-list">
<For each={props.memberVisuals}>
{(item) => (
<article class="member-visual-card">
<header>
<strong>{item.member.displayName}</strong>
<span class={`balance-status ${props.remainingClass(item.member)}`}>
{item.member.remainingMajor} {props.dashboard.currency}
</span>
</header>
<div class="member-visual-bar">
<div
class="member-visual-bar__track"
style={{ width: `${item.barWidthPercent}%` }}
>
<For each={item.segments}>
{(segment) => (
<span
class={`member-visual-bar__segment member-visual-bar__segment--${segment.key}`}
style={{ width: `${segment.widthPercent}%` }}
/>
)}
</For>
</div>
</div>
<div class="member-visual-meta">
<For each={item.segments}>
{(segment) => (
<span class={`member-visual-chip member-visual-chip--${segment.key}`}>
{segment.label}: {segment.amountMajor} {props.dashboard.currency}
</span>
)}
</For>
</div>
</article>
)}
</For>
</div>
</article>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.labels.purchaseInvestmentsTitle}</strong>
<span>
{props.labels.purchaseTotalLabel}: {props.purchaseChart.totalMajor}{' '}
{props.dashboard.currency}
</span>
</header>
<p>{props.labels.purchaseInvestmentsBody}</p>
<Switch>
<Match when={props.purchaseChart.slices.length === 0}>
<p>{props.labels.purchaseInvestmentsEmpty}</p>
</Match>
<Match when={props.purchaseChart.slices.length > 0}>
<div class="purchase-chart">
<div class="purchase-chart__figure">
<svg class="purchase-chart__donut" viewBox="0 0 120 120" aria-hidden="true">
<circle class="purchase-chart__ring" cx="60" cy="60" r="42" />
<For each={props.purchaseChart.slices}>
{(slice) => (
<circle
class="purchase-chart__slice"
cx="60"
cy="60"
r="42"
stroke={slice.color}
stroke-dasharray={slice.dasharray}
stroke-dashoffset={slice.dashoffset}
/>
)}
</For>
</svg>
<div class="purchase-chart__center">
<strong>{props.purchaseChart.totalMajor}</strong>
<small>{props.dashboard.currency}</small>
</div>
</div>
<div class="purchase-chart__legend">
<For each={props.purchaseChart.slices}>
{(slice) => (
<article class="purchase-chart__legend-item">
<div>
<span
class="purchase-chart__legend-swatch"
style={{ 'background-color': slice.color }}
/>
<strong>{slice.label}</strong>
</div>
<p>
{slice.amountMajor} {props.dashboard.currency} ·{' '}
{props.labels.purchaseShareLabel} {slice.percentage}%
</p>
</article>
)}
</For>
</div>
</div>
</Match>
</Switch>
</article>
</>
)
}

View File

@@ -1,153 +0,0 @@
import { Show } from 'solid-js'
import { cn } from '../../lib/cn'
import { formatCyclePeriod, formatFriendlyDate } from '../../lib/dates'
import { majorStringToMinor, sumMajorStrings } from '../../lib/money'
import type { MiniAppDashboard } from '../../miniapp-api'
import { MiniChip, StatCard } from '../ui'
type Props = {
copy: Record<string, string | undefined>
locale: 'en' | 'ru'
dashboard: MiniAppDashboard
member: MiniAppDashboard['members'][number]
detail?: boolean
}
export function MemberBalanceCard(props: Props) {
const utilitiesAdjustedMajor = () =>
sumMajorStrings(props.member.utilityShareMajor, props.member.purchaseOffsetMajor)
const adjustmentClass = () => {
const value = majorStringToMinor(props.member.purchaseOffsetMajor)
if (value < 0n) {
return 'is-credit'
}
if (value > 0n) {
return 'is-due'
}
return 'is-settled'
}
return (
<article
class={cn(
'balance-item',
'balance-item--accent',
'balance-spotlight',
props.detail && 'balance-spotlight--detail'
)}
>
<header class="balance-spotlight__header">
<div class="balance-spotlight__copy">
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
<Show when={props.copy.yourBalanceBody}>{(body) => <p>{body()}</p>}</Show>
</div>
<div class="balance-spotlight__hero">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{props.member.remainingMajor} {props.dashboard.currency}
</strong>
<Show when={majorStringToMinor(props.member.paidMajor) > 0n}>
<small>
{props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency}
</small>
</Show>
</div>
</header>
<div class="balance-spotlight__stats">
<StatCard class="balance-spotlight__stat">
<span>{props.copy.currentCycleLabel ?? ''}</span>
<strong>{formatCyclePeriod(props.dashboard.period, props.locale)}</strong>
</StatCard>
<StatCard class="balance-spotlight__stat">
<span>{props.copy.paidLabel ?? ''}</span>
<strong>
{props.member.paidMajor} {props.dashboard.currency}
</strong>
</StatCard>
<StatCard class="balance-spotlight__stat">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{props.member.remainingMajor} {props.dashboard.currency}
</strong>
</StatCard>
</div>
<div class="balance-spotlight__rows">
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.shareRent ?? ''}</span>
<strong>
{props.member.rentShareMajor} {props.dashboard.currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities ?? ''}</span>
<strong>
{props.member.utilityShareMajor} {props.dashboard.currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset ?? ''}</span>
<strong class={`balance-status ${adjustmentClass()}`}>
{props.member.purchaseOffsetMajor} {props.dashboard.currency}
</strong>
</div>
</article>
<Show when={props.dashboard.paymentBalanceAdjustmentPolicy === 'utilities'}>
<article class="balance-detail-row balance-detail-row--accent">
<div class="balance-detail-row__main">
<span>{props.copy.utilitiesAdjustedTotalLabel ?? ''}</span>
<strong>
{utilitiesAdjustedMajor()} {props.dashboard.currency}
</strong>
</div>
</article>
</Show>
</div>
<Show when={props.dashboard.rentSourceCurrency !== props.dashboard.currency}>
<section class="fx-panel">
<header class="fx-panel__header">
<strong>{props.copy.rentFxTitle ?? ''}</strong>
<Show when={props.dashboard.rentFxEffectiveDate}>
{(date) => (
<MiniChip muted>
{props.copy.fxEffectiveDateLabel ?? ''}:{' '}
{formatFriendlyDate(date(), props.locale)}
</MiniChip>
)}
</Show>
</header>
<div class="fx-panel__grid">
<article class="fx-panel__cell">
<span>{props.copy.sourceAmountLabel ?? ''}</span>
<strong>
{props.dashboard.rentSourceAmountMajor} {props.dashboard.rentSourceCurrency}
</strong>
</article>
<article class="fx-panel__cell">
<span>{props.copy.settlementAmountLabel ?? ''}</span>
<strong>
{props.dashboard.rentDisplayAmountMajor} {props.dashboard.currency}
</strong>
</article>
</div>
</section>
</Show>
</article>
)
}

View File

@@ -1,28 +1,45 @@
import type { JSX } from 'solid-js'
import { useNavigate, useLocation } from '@solidjs/router'
import { Home, Wallet, BookOpen } from 'lucide-solid'
import { type JSX } from 'solid-js'
type TabItem<T extends string> = {
key: T
import { useI18n } from '../../contexts/i18n-context'
type TabItem = {
path: string
label: string
icon?: JSX.Element
icon: JSX.Element
}
type Props<T extends string> = {
items: readonly TabItem<T>[]
active: T
onChange: (key: T) => void
}
/**
* Bottom navigation bar with 3 tabs (Bug #6 fix: reduced to 3 tabs,
* settings moved to top bar gear icon).
*/
export function NavigationTabs(): JSX.Element {
const navigate = useNavigate()
const location = useLocation()
const { copy } = useI18n()
const tabs = (): TabItem[] => [
{ path: '/', label: copy().home, icon: <Home size={20} /> },
{ path: '/balances', label: copy().balances, icon: <Wallet size={20} /> },
{ path: '/ledger', label: copy().ledger, icon: <BookOpen size={20} /> }
]
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/'
return location.pathname.startsWith(path)
}
export function NavigationTabs<T extends string>(props: Props<T>): JSX.Element {
return (
<nav class="nav-grid">
{props.items.map((item) => (
{tabs().map((tab) => (
<button
classList={{ 'is-active': props.active === item.key }}
classList={{ 'is-active': isActive(tab.path) }}
type="button"
onClick={() => props.onChange(item.key)}
onClick={() => navigate(tab.path)}
>
{item.icon}
<span>{item.label}</span>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</nav>

View File

@@ -1,25 +0,0 @@
import { Card, MiniChip } from '../ui'
type Props = {
displayName: string
roleLabel: string
statusSummary: string
modeBadge: string
localeBadge: string
}
export function ProfileCard(props: Props) {
return (
<Card class="profile-card" accent>
<header>
<strong>{props.displayName}</strong>
<span>{props.roleLabel}</span>
</header>
<p>{props.statusSummary}</p>
<div class="ledger-compact-card__meta">
<MiniChip>{props.modeBadge}</MiniChip>
<MiniChip muted>{props.localeBadge}</MiniChip>
</div>
</Card>
)
}

View File

@@ -0,0 +1,164 @@
import { useNavigate } from '@solidjs/router'
import { Show, createSignal, type ParentProps } from 'solid-js'
import { Settings } from 'lucide-solid'
import { useSession } from '../../contexts/session-context'
import { useI18n } from '../../contexts/i18n-context'
import { useDashboard } from '../../contexts/dashboard-context'
import { NavigationTabs } from './navigation-tabs'
import { Badge } from '../ui/badge'
import { Button, IconButton } from '../ui/button'
import { Modal } from '../ui/dialog'
export function AppShell(props: ParentProps) {
const { readySession } = useSession()
const { copy, locale, setLocale } = useI18n()
const { effectiveIsAdmin, testingRolePreview, setTestingRolePreview } = useDashboard()
const navigate = useNavigate()
const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false)
function memberStatusLabel(status: 'active' | 'away' | 'left') {
const labels = {
active: copy().memberStatusActive,
away: copy().memberStatusAway,
left: copy().memberStatusLeft
}
return labels[status]
}
let tapCount = 0
let tapTimer: ReturnType<typeof setTimeout> | undefined
function handleRoleChipTap() {
tapCount++
if (tapCount >= 5) {
setTestingSurfaceOpen(true)
tapCount = 0
}
clearTimeout(tapTimer)
tapTimer = setTimeout(() => {
tapCount = 0
}, 1000)
}
return (
<main class="shell">
{/* ── Top bar ──────────────────────────────────── */}
<section class="topbar">
<div class="topbar__copy">
<p class="eyebrow">{copy().appSubtitle}</p>
<h1>{readySession()?.member.householdName ?? copy().appTitle}</h1>
</div>
<div class="topbar__actions">
<div class="locale-switch locale-switch--compact">
<div class="locale-switch__buttons">
<button
classList={{ 'is-active': locale() === 'en' }}
type="button"
onClick={() => setLocale('en')}
>
EN
</button>
<button
classList={{ 'is-active': locale() === 'ru' }}
type="button"
onClick={() => setLocale('ru')}
>
RU
</button>
</div>
</div>
<IconButton label="Settings" onClick={() => navigate('/settings')}>
<Settings size={18} />
</IconButton>
</div>
</section>
{/* ── Context badges ───────────────────────────── */}
<section class="app-context-row">
<div class="app-context-meta">
<Badge variant={readySession()?.mode === 'demo' ? 'accent' : 'default'}>
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
</Badge>
<Show
when={readySession()?.member.isAdmin}
fallback={
<Badge variant="muted">
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
</Badge>
}
>
<button class="ui-badge ui-badge--muted" onClick={handleRoleChipTap}>
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
</button>
</Show>
<Badge variant="muted">
{readySession()?.member.status
? memberStatusLabel(readySession()!.member.status)
: copy().memberStatusActive}
</Badge>
<Show when={testingRolePreview()}>
{(preview) => (
<Badge variant="accent">
{`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`}
</Badge>
)}
</Show>
</div>
</section>
{/* ── Route content ────────────────────────────── */}
<section class="content-stack">{props.children}</section>
{/* ── Bottom nav (Bug #6: 3 tabs, proper padding) */}
<div class="app-bottom-nav">
<NavigationTabs />
</div>
{/* ── Modals at route/shell level (Bug #1/#2 fix) */}
<Modal
open={testingSurfaceOpen()}
title={copy().testingSurfaceTitle ?? ''}
description={copy().testingSurfaceBody}
closeLabel={copy().closeEditorAction}
onClose={() => setTestingSurfaceOpen(false)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setTestingSurfaceOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button variant="secondary" onClick={() => setTestingRolePreview(null)}>
{copy().testingUseRealRoleAction ?? ''}
</Button>
</div>
}
>
<div class="testing-card">
<article class="testing-card__section">
<span>{copy().testingCurrentRoleLabel ?? ''}</span>
<strong>{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}</strong>
</article>
<article class="testing-card__section">
<span>{copy().testingPreviewRoleLabel ?? ''}</span>
<strong>
{testingRolePreview()
? testingRolePreview() === 'admin'
? copy().adminTag
: copy().residentTag
: copy().testingUseRealRoleAction}
</strong>
</article>
<div class="testing-card__actions">
<Button variant="secondary" onClick={() => setTestingRolePreview('admin')}>
{copy().testingPreviewAdminAction ?? ''}
</Button>
<Button variant="secondary" onClick={() => setTestingRolePreview('resident')}>
{copy().testingPreviewResidentAction ?? ''}
</Button>
</div>
</div>
</Modal>
</main>
)
}

View File

@@ -1,49 +0,0 @@
import type { Locale } from '../../i18n'
import { GlobeIcon } from '../ui'
type Props = {
subtitle: string
title: string
languageLabel: string
locale: Locale
saving: boolean
onChange: (locale: Locale) => void
}
export function TopBar(props: Props) {
return (
<section class="topbar">
<div class="topbar__copy">
<p class="eyebrow">{props.subtitle}</p>
<h1>{props.title}</h1>
</div>
<div class="locale-switch locale-switch--compact">
<span class="locale-switch__label sr-only">{props.languageLabel}</span>
<div class="locale-switch__buttons">
<span class="locale-switch__icon" aria-hidden="true">
<GlobeIcon />
</span>
<button
classList={{ 'is-active': props.locale === 'en' }}
type="button"
disabled={props.saving}
aria-label={`${props.languageLabel}: English`}
onClick={() => props.onChange('en')}
>
EN
</button>
<button
classList={{ 'is-active': props.locale === 'ru' }}
type="button"
disabled={props.saving}
aria-label={`${props.languageLabel}: Russian`}
onClick={() => props.onChange('ru')}
>
RU
</button>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,26 @@
import type { ParentProps } from 'solid-js'
import { cn } from '../../lib/cn'
type BadgeProps = ParentProps<{
variant?: 'default' | 'muted' | 'accent' | 'danger'
class?: string
}>
export function Badge(props: BadgeProps) {
return (
<span
class={cn(
'ui-badge',
{
'ui-badge--muted': props.variant === 'muted',
'ui-badge--accent': props.variant === 'accent',
'ui-badge--danger': props.variant === 'danger'
},
props.class
)}
>
{props.children}
</span>
)
}

View File

@@ -1,5 +1,6 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { JSX, ParentProps } from 'solid-js'
import { Show, type JSX, type ParentProps } from 'solid-js'
import { Loader2 } from 'lucide-solid'
import { cn } from '../../lib/cn'
@@ -11,10 +12,16 @@ const buttonVariants = cva('ui-button', {
danger: 'ui-button--danger',
ghost: 'ui-button--ghost',
icon: 'ui-button--icon'
},
size: {
sm: 'ui-button--sm',
md: '',
lg: 'ui-button--lg'
}
},
defaultVariants: {
variant: 'secondary'
variant: 'secondary',
size: 'md'
}
})
@@ -22,6 +29,7 @@ type ButtonProps = ParentProps<{
type?: 'button' | 'submit' | 'reset'
class?: string
disabled?: boolean
loading?: boolean
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
}> &
VariantProps<typeof buttonVariants>
@@ -30,10 +38,13 @@ export function Button(props: ButtonProps) {
return (
<button
type={props.type ?? 'button'}
class={cn(buttonVariants({ variant: props.variant }), props.class)}
disabled={props.disabled}
class={cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)}
disabled={props.disabled || props.loading}
onClick={props.onClick}
>
<Show when={props.loading}>
<Loader2 class="ui-button__spinner" size={16} />
</Show>
{props.children}
</button>
)

View File

@@ -2,9 +2,19 @@ import type { ParentProps } from 'solid-js'
import { cn } from '../../lib/cn'
export function Card(props: ParentProps<{ class?: string; accent?: boolean }>) {
export function Card(
props: ParentProps<{ class?: string; accent?: boolean; muted?: boolean; wide?: boolean }>
) {
return (
<article class={cn('balance-item', props.accent && 'balance-item--accent', props.class)}>
<article
class={cn(
'ui-card',
props.accent && 'ui-card--accent',
props.muted && 'ui-card--muted',
props.wide && 'ui-card--wide',
props.class
)}
>
{props.children}
</article>
)
@@ -14,6 +24,7 @@ export function StatCard(props: ParentProps<{ class?: string }>) {
return <article class={cn('stat-card', props.class)}>{props.children}</article>
}
/** @deprecated Use Badge component instead */
export function MiniChip(props: ParentProps<{ muted?: boolean; class?: string }>) {
return (
<span class={cn('mini-chip', props.muted && 'mini-chip--muted', props.class)}>

View File

@@ -0,0 +1,32 @@
import * as CollapsiblePrimitive from '@kobalte/core/collapsible'
import { Show, type ParentProps } from 'solid-js'
import { ChevronDown } from 'lucide-solid'
import { cn } from '../../lib/cn'
type CollapsibleProps = ParentProps<{
title: string
body?: string
defaultOpen?: boolean
class?: string
}>
export function Collapsible(props: CollapsibleProps) {
return (
<CollapsiblePrimitive.Root
{...(props.defaultOpen !== undefined ? { defaultOpen: props.defaultOpen } : {})}
class={cn('ui-collapsible', props.class)}
>
<CollapsiblePrimitive.Trigger class="ui-collapsible__trigger">
<div class="ui-collapsible__copy">
<strong>{props.title}</strong>
<Show when={props.body}>{(body) => <p>{body()}</p>}</Show>
</div>
<ChevronDown class="ui-collapsible__chevron" size={18} />
</CollapsiblePrimitive.Trigger>
<CollapsiblePrimitive.Content class="ui-collapsible__content">
{props.children}
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
)
}

View File

@@ -11,10 +11,10 @@ export function Field(
}>
) {
return (
<label class={cn('settings-field', props.wide && 'settings-field--wide', props.class)}>
<span>{props.label}</span>
<label class={cn('ui-field', props.wide && 'ui-field--wide', props.class)}>
<span class="ui-field__label">{props.label}</span>
{props.children}
<Show when={props.hint}>{(hint) => <small>{hint()}</small>}</Show>
<Show when={props.hint}>{(hint) => <small class="ui-field__hint">{hint()}</small>}</Show>
</label>
)
}

View File

@@ -3,3 +3,9 @@ export * from './card'
export * from './dialog'
export * from './field'
export * from './icons'
export * from './input'
export * from './select'
export * from './toggle'
export * from './collapsible'
export * from './badge'
export * from './skeleton'

View File

@@ -0,0 +1,65 @@
import type { JSX } from 'solid-js'
import { cn } from '../../lib/cn'
type InputProps = {
value?: string
placeholder?: string
type?: 'text' | 'number' | 'email'
min?: string | number
max?: string | number
step?: string | number
maxlength?: number
disabled?: boolean
invalid?: boolean
class?: string
style?: JSX.CSSProperties
list?: string
id?: string
onInput?: JSX.EventHandlerUnion<HTMLInputElement, InputEvent>
onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event>
}
export function Input(props: InputProps) {
return (
<input
type={props.type ?? 'text'}
value={props.value ?? ''}
placeholder={props.placeholder}
min={props.min}
max={props.max}
step={props.step}
maxlength={props.maxlength}
disabled={props.disabled}
aria-invalid={props.invalid}
style={props.style}
list={props.list}
id={props.id}
class={cn('ui-input', props.class)}
onInput={props.onInput}
onChange={props.onChange}
/>
)
}
export function Textarea(props: {
value?: string
placeholder?: string
rows?: number
maxlength?: number
disabled?: boolean
class?: string
onInput?: JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>
}) {
return (
<textarea
value={props.value ?? ''}
placeholder={props.placeholder}
rows={props.rows ?? 4}
maxlength={props.maxlength}
disabled={props.disabled}
class={cn('ui-input ui-textarea', props.class)}
onInput={props.onInput}
/>
)
}

View File

@@ -0,0 +1,66 @@
import * as SelectPrimitive from '@kobalte/core/select'
import { Check, ChevronDown } from 'lucide-solid'
import { cn } from '../../lib/cn'
export type SelectOption = {
value: string
label: string
}
type SelectProps = {
value?: string
options: readonly SelectOption[]
disabled?: boolean
class?: string
id?: string
ariaLabel: string
placeholder?: string
onChange?: (value: string) => void
}
export function Select(props: SelectProps) {
const selectedOption = () =>
props.options.find((option) => option.value === (props.value ?? '')) ?? null
const optionalRootProps = {
...(props.disabled !== undefined ? { disabled: props.disabled } : {}),
...(props.id !== undefined ? { id: props.id } : {}),
...(props.placeholder !== undefined ? { placeholder: props.placeholder } : {})
}
return (
<SelectPrimitive.Root<SelectOption>
value={selectedOption()}
options={[...props.options]}
optionValue="value"
optionTextValue="label"
onChange={(option) => props.onChange?.(option?.value ?? '')}
itemComponent={(itemProps) => (
<SelectPrimitive.Item item={itemProps.item} class="ui-select__item">
<SelectPrimitive.ItemLabel class="ui-select__item-label">
{itemProps.item.rawValue.label}
</SelectPrimitive.ItemLabel>
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
<Check size={14} />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)}
{...optionalRootProps}
>
<SelectPrimitive.HiddenSelect />
<SelectPrimitive.Trigger class={cn('ui-select', props.class)} aria-label={props.ariaLabel}>
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
{(state) => state.selectedOption()?.label ?? props.placeholder ?? ''}
</SelectPrimitive.Value>
<SelectPrimitive.Icon class="ui-select__icon">
<ChevronDown size={16} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content class="ui-select__content">
<SelectPrimitive.Listbox class="ui-select__listbox" />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
)
}

View File

@@ -0,0 +1,19 @@
import { cn } from '../../lib/cn'
type SkeletonProps = {
class?: string
width?: string
height?: string
}
export function Skeleton(props: SkeletonProps) {
return (
<div
class={cn('ui-skeleton', props.class)}
style={{
width: props.width,
height: props.height
}}
/>
)
}

View File

@@ -0,0 +1,29 @@
import * as SwitchPrimitive from '@kobalte/core/switch'
import { cn } from '../../lib/cn'
type ToggleProps = {
checked: boolean
disabled?: boolean
label?: string
class?: string
onChange: (checked: boolean) => void
}
export function Toggle(props: ToggleProps) {
return (
<SwitchPrimitive.Root
checked={props.checked}
{...(props.disabled !== undefined ? { disabled: props.disabled } : {})}
onChange={props.onChange}
class={cn('ui-toggle', props.class)}
>
<SwitchPrimitive.Input />
<SwitchPrimitive.Control class="ui-toggle__track">
<SwitchPrimitive.Thumb class="ui-toggle__thumb" />
</SwitchPrimitive.Control>
{props.label && (
<SwitchPrimitive.Label class="ui-toggle__label">{props.label}</SwitchPrimitive.Label>
)}
</SwitchPrimitive.Root>
)
}