Files
accounted/components/settings/SettingsRows.tsx
T
Jakob Wennberg 0449b1d0ea feat(settings): WhatsApp brand mark on the WhatsApp settings page (#1418)
The page had no visual signal that it configures a third-party channel,
so it read like any other Accounted setting. Adds an inline-SVG WhatsApp
mark next to the section title and on the button that opens WhatsApp
(replacing the generic lucide chat bubble, which was standing in for a
logo it is not).

Inline SVG rather than a bundled asset: no network request, scales, and
survives the CSP. SettingsSectionHeader gains an optional `mark` slot;
every other settings tab is untouched and stays mark-less on purpose, so
the rail does not turn into a sticker album. The green is the one place
brand colour appears in settings, which matches the design rule that
colour belongs to actors rather than chrome.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:30:13 +02:00

269 lines
9.0 KiB
TypeScript

'use client'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { HelpPopover } from '@/components/ui/help-popover'
/**
* The Fönster settings language (founder-approved concept 2026-07-25):
* flat hairline rows with an uppercase micro-label on the left and the
* control on the right, section titles in the display serif, groups under
* eyebrow labels, and every explanation collapsed behind a "?" popover
* (UI-migration convention 7 applied at row level).
*
* All settings sections compose these primitives; do not hand-roll row
* layouts or inline help paragraphs in section components.
*/
interface SettingsSectionHeaderProps {
title: string
/** One quiet line under the title. Longer guidance belongs in row help. */
intro?: React.ReactNode
/** Right-aligned header action (e.g. "Förhandsvisa faktura", "Skapa nyckel"). */
action?: React.ReactNode
/**
* Brand mark for a section that represents a third-party channel
* (WhatsApp today). Sections that are just Accounted settings leave this
* unset: the serif title carries them, and a decorative icon per tab
* would turn the settings rail into a sticker album.
*/
mark?: React.ReactNode
}
export function SettingsSectionHeader({ title, intro, action, mark }: SettingsSectionHeaderProps) {
return (
<header>
<div className="flex items-baseline justify-between gap-4">
<div className="flex items-center gap-2.5">
{mark ? <span className="shrink-0 leading-none">{mark}</span> : null}
<h2 className="font-display text-xl tracking-tight">{title}</h2>
</div>
{action ? <div className="flex shrink-0 items-center gap-3">{action}</div> : null}
</div>
{intro ? (
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">{intro}</p>
) : null}
</header>
)
}
interface SettingsGroupProps {
label?: string
/** Group-level help ("?" right after the eyebrow) for guidance that spans the rows. */
help?: React.ReactNode
children: React.ReactNode
className?: string
}
export function SettingsGroup({ label, help, children, className }: SettingsGroupProps) {
return (
<section className={cn('pt-8 first:pt-6', className)}>
{label ? (
<p className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<span>{label}</span>
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</p>
) : null}
<div>{children}</div>
</section>
)
}
interface SettingsRowProps {
label: React.ReactNode
/** Ties the label to a control; renders a <label> instead of a <span>. */
htmlFor?: string
/** Row help content: goes behind a "?" next to the label, never inline. */
help?: React.ReactNode
/** 'center' for toggles/selects/chips, 'baseline' for text inputs. */
align?: 'center' | 'baseline'
/** Drop the hairline (last row before a fold/reveal). */
borderless?: boolean
children: React.ReactNode
className?: string
}
export function SettingsRow({
label,
htmlFor,
help,
align = 'center',
borderless = false,
children,
className,
}: SettingsRowProps) {
const labelClass = 'text-[11px] font-medium uppercase tracking-wider text-muted-foreground'
return (
<div
className={cn(
'flex flex-col gap-1 px-1 py-3 md:flex-row md:gap-4',
align === 'center' ? 'md:items-center' : 'md:items-baseline',
!borderless && 'border-b border-border',
className,
)}
>
<div className="flex w-full shrink-0 items-center gap-2 md:w-44">
{htmlFor ? (
<label htmlFor={htmlFor} className={labelClass}>
{label}
</label>
) : (
<span className={labelClass}>{label}</span>
)}
{help ? <HelpPopover className="shrink-0">{help}</HelpPopover> : null}
</div>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{children}
</div>
</div>
)
}
/** Right-aligned slot inside a row (quiet actions, secondary values). */
export function SettingsRowEnd({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn('ml-auto flex shrink-0 items-center gap-3', className)}>{children}</div>
}
/** Muted secondary text inside a row. */
export function SettingsRowNote({ children, className }: { children: React.ReactNode; className?: string }) {
return <span className={cn('text-xs text-muted-foreground', className)}>{children}</span>
}
/**
* Flat text input: borderless with a dashed underline on hover and a solid
* one on focus. Sized by the row, not by a box.
*/
export function SettingsInput({ className, ...rest }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...rest}
className={cn(
'min-w-0 flex-1 rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
'placeholder:text-muted-foreground/60 hover:border-border',
'focus:outline-none focus:border-solid focus:border-foreground/50',
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
className,
)}
/>
)
}
/** Flat textarea sibling of SettingsInput. */
export function SettingsTextarea({ className, ...rest }: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
{...rest}
className={cn(
'min-w-0 flex-1 resize-y rounded-none border-0 border-b border-dashed border-transparent bg-transparent px-0 py-1 text-sm text-foreground',
'placeholder:text-muted-foreground/60 hover:border-border',
'focus:outline-none focus:border-solid focus:border-foreground/50',
'disabled:cursor-not-allowed disabled:border-transparent disabled:opacity-60',
className,
)}
/>
)
}
/** Flat native select with a quiet chevron. */
export function SettingsSelect({
className,
wrapperClassName,
children,
...rest
}: React.SelectHTMLAttributes<HTMLSelectElement> & { wrapperClassName?: string }) {
return (
<span className={cn('relative inline-flex max-w-full items-center', wrapperClassName)}>
<select
{...rest}
className={cn(
'max-w-full cursor-pointer appearance-none truncate rounded-none border-0 border-b border-dashed border-transparent bg-transparent py-1 pl-0 pr-6 text-sm text-foreground',
'hover:border-border focus:outline-none focus-visible:border-solid focus-visible:border-foreground/50',
'disabled:cursor-not-allowed disabled:opacity-60',
className,
)}
>
{children}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-1 h-3.5 w-3.5 text-muted-foreground"
/>
</span>
)
}
interface SettingsRevealProps {
open: boolean
/** Indent revealed rows behind a left hairline (gated sub-settings). */
indent?: boolean
children: React.ReactNode
}
/** Animated reveal for settings gated behind a toggle (momsreg → momsblock). */
export function SettingsReveal({ open, indent = true, children }: SettingsRevealProps) {
return (
<div
inert={!open}
className={cn(
'grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="min-h-0 overflow-hidden">
<div className={cn(indent && 'ml-3 border-l border-border pl-4')}>{children}</div>
</div>
</div>
)
}
interface SettingsSegProps<T extends string> {
value: T
onChange: (value: T) => void
options: Array<{ value: T; label: React.ReactNode }>
'aria-label': string
disabled?: boolean
}
/** Quiet segmented control (theme, language, plan interval, sv/en texts). */
export function SettingsSeg<T extends string>({
value,
onChange,
options,
'aria-label': ariaLabel,
disabled,
}: SettingsSegProps<T>) {
return (
<div role="group" aria-label={ariaLabel} className="inline-flex items-center gap-1 rounded-lg bg-muted/70 p-1">
{options.map((o) => (
<button
key={o.value}
type="button"
disabled={disabled}
aria-pressed={o.value === value}
onClick={() => onChange(o.value)}
className={cn(
'rounded-md px-3 py-1 text-xs transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-60',
o.value === value
? 'border border-border bg-card font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
</button>
))}
</div>
)
}
/** Terracotta-tinted trailing block for destructive actions. */
export function SettingsDangerZone({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="mt-10 border-t border-destructive/30 pt-3">
<p className="px-1 text-[11px] font-medium uppercase tracking-wider text-destructive/80">
{label}
</p>
<div>{children}</div>
</section>
)
}