feat(ui): add <CardButton> primitive (UI audit Pattern A fix)
Some checks failed
CI — Common Platform / Build, Test & Typecheck (push) Has been cancelled
CI — Common Platform / Publish @bytelyst/* to Gitea npm registry (push) Has been cancelled

The Button primitive applies `whitespace-nowrap` + a fixed `h-{size}`
because it's tuned for single-line CTAs. Consumers using Button as a
card-shaped picker — title above description, icon next to multi-line
label — hit collapsed/clipped content because of those constraints.

CardButton is the right primitive for that use case:

  - block layout, full-width, left-aligned by default
  - whitespace: normal so multi-line content wraps
  - height: auto so any number of stacked rows works
  - focus-visible ring tied to --bl-focus-ring/--bl-accent (matches Button)
  - disabled opacity + pointer-events
  - selected? prop with subtle inset accent ring (override-able)
  - asChild support via Radix Slot for <Link>/<a> handoff

Bumps @bytelyst/ui to 0.1.6 and re-exports CardButton + CardButtonProps
from the package entry point.

Initial consumers: 5 sites in learning_ai_invt_trdg (StrategyWizard
risk + hours pickers, SimpleView buy + sell plan cards, MyStrategiesTab
diagnostic toggle). See docs/ui/UI_AUDIT.md Pattern A in that repo.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin 2026-05-10 09:31:29 +00:00
parent d2420f5d3c
commit 953730ff51
3 changed files with 73 additions and 1 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@bytelyst/ui",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"storybook": "storybook dev -p 6006",

View File

@ -0,0 +1,71 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { clsx } from 'clsx';
/**
* CardButton a button-shaped CARD that allows stacked, multi-line content.
*
* The {@link Button} primitive applies `whitespace-nowrap` and a fixed
* `h-{size}` because it's tuned for single-line CTAs (Save, Cancel, Submit).
* When you put two stacked spans inside it (e.g. a title + a description),
* the second collapses or overflows the fixed height. This is by design
* Button is the wrong primitive for picker/option/action *cards*.
*
* CardButton drops those constraints and gives you:
* - `display: block` (or flex via `as-child`)
* - `white-space: normal` so text wraps
* - `height: auto` so any number of stacked rows work
* - the same focus-visible ring as Button (keyboard a11y preserved)
* - `disabled` opacity behavior
*
* Use it whenever a `<Button>` would otherwise wrap a `<div>` or multiple
* stacked `<span>`s. See `docs/ui/UI_AUDIT.md` Pattern A in any consumer
* repo for the full background.
*
* @example
* <CardButton onClick={pickRiskStyle}>
* <div className="font-bold">Aggressive</div>
* <div className="text-xs text-muted">Higher risk, higher reward.</div>
* </CardButton>
*/
export interface CardButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
selected?: boolean;
/** Render as an arbitrary element (e.g. <Link>) via Radix Slot */
asChild?: boolean;
}
export const CardButton = React.forwardRef<HTMLButtonElement, CardButtonProps>(
({ asChild, selected, className, type, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const baseStyles = clsx(
// Reset native button styling
'appearance-none bg-transparent border-0 font-inherit text-inherit',
// Layout
'block w-full text-left',
// Allow stacked / multi-line content (the entire point of this primitive)
'whitespace-normal h-auto',
// Smooth transitions
'transition duration-150',
// Focus ring matched to @bytelyst/ui Button
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-focus-ring,var(--bl-accent,#5A8CFF))] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bl-bg-canvas,#0b0f17)]',
// Disabled
'disabled:pointer-events-none disabled:opacity-50',
// Selected state — consumers can override but a sensible default
selected && 'shadow-[inset_0_0_0_2px_var(--bl-accent,#5A8CFF)]'
);
return (
<Comp
ref={ref}
type={asChild ? undefined : (type ?? 'button')}
className={clsx(baseStyles, className)}
data-card-button
data-selected={selected ? 'true' : undefined}
{...props}
/>
);
}
);
CardButton.displayName = 'CardButton';

View File

@ -1,4 +1,5 @@
export { Button, type ButtonProps } from './components/Button.js';
export { CardButton, type CardButtonProps } from './components/CardButton.js';
export { PageHeader, type PageHeaderProps } from './components/PageHeader.js';
export { Section, type SectionProps } from './components/Section.js';
export { Toolbar, type ToolbarProps } from './components/Toolbar.js';