feat(ui): add <CardButton> primitive (UI audit Pattern A fix)
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:
parent
d2420f5d3c
commit
953730ff51
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@bytelyst/ui",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"storybook": "storybook dev -p 6006",
|
||||
|
||||
71
packages/ui/src/components/CardButton.tsx
Normal file
71
packages/ui/src/components/CardButton.tsx
Normal 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';
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user