UI base acessível para React com elementos nativos: campos, botões, tabela, abas, diálogo, avisos e tokens AA claro/escuro
Instalar
genpm add @core/uiO que você recebe
- Código em src/lib/ui/, 5 arquivos. (19,2 kB)
- Regras de IA em src/lib/ui/AGENTS.md, mais arquivos de regras da IDE.
README
Este pacote não tem README.
Isto é exatamente o que sua IA lê quando trabalha em src/lib/ui. Nada mais é adicionado ao contexto dela.
@core/ui — rules for AI agents
Purpose
Small accessible component set for React built on native elements (no UI library): Button, TextField,
TextareaField, SelectField, Checkbox, Field (label + help + error wiring), DataTable (with aria-sort),
Pagination, Tabs (WAI-ARIA, arrow keys), Dialog (native <dialog>), Alert, Badge, Card, Toasts,
Spinner, Skeleton. Styling lives in ui.css as CSS variables (light/dark, WCAG AA contrast) and ui-* classes.
Used by @core/admin, @core/blocks and the kits.
Map
index.ts— all components andcx().components.ts— implementation.ui.css— tokens and styles.
Integration
- Import the stylesheet once in the root layout:
import '@/lib/ui/ui.css'and addclassName="ui-root"to<body>. - Use components:
<TextField label="Email" type="email" required error={errors.email} />,<Button variant="primary" loading={saving}>Save</Button>. - Theme: change
--ui-*variables in your CSS; force a theme with<html data-theme="dark">. - Verify: tab through a form; every control has a visible focus ring and a label.
Conventions
- Every field gets a visible label (use
hideLabelonly when context makes it obvious); errors go inerror, not in placeholders. - Interactive elements are real
<button>/<a>/inputs; don't add click handlers to<div>. - Pass translated
labelstoPaginationanddismissLabeltoToasts.
Don't
- Don't lower contrast below 4.5:1 when changing tokens, or remove the focus outline.
- Don't add app-specific components here; keep them in your app or kit.
# @core/ui — rules for AI agents
## Purpose
Small accessible component set for React built on native elements (no UI library): `Button`, `TextField`,
`TextareaField`, `SelectField`, `Checkbox`, `Field` (label + help + error wiring), `DataTable` (with `aria-sort`),
`Pagination`, `Tabs` (WAI-ARIA, arrow keys), `Dialog` (native `<dialog>`), `Alert`, `Badge`, `Card`, `Toasts`,
`Spinner`, `Skeleton`. Styling lives in `ui.css` as CSS variables (light/dark, WCAG AA contrast) and `ui-*` classes.
Used by @core/admin, @core/blocks and the kits.
## Map
- `index.ts` — all components and `cx()`.
- `components.ts` — implementation. `ui.css` — tokens and styles.
## Integration
1. Import the stylesheet once in the root layout: `import '@/lib/ui/ui.css'` and add `className="ui-root"` to `<body>`.
2. Use components: `<TextField label="Email" type="email" required error={errors.email} />`, `<Button variant="primary" loading={saving}>Save</Button>`.
3. Theme: change `--ui-*` variables in your CSS; force a theme with `<html data-theme="dark">`.
4. Verify: tab through a form; every control has a visible focus ring and a label.
## Conventions
- Every field gets a visible label (use `hideLabel` only when context makes it obvious); errors go in `error`, not in placeholders.
- Interactive elements are real `<button>`/`<a>`/inputs; don't add click handlers to `<div>`.
- Pass translated `labels` to `Pagination` and `dismissLabel` to `Toasts`.
## Don't
- Don't lower contrast below 4.5:1 when changing tokens, or remove the focus outline.
- Don't add app-specific components here; keep them in your app or kit.
A árvore exata que será injetada, após o .genpmignore. Fixada em
// Componentes base accesibles sobre elementos nativos (sin dependencias de UI). Estilos en ui.css (clases `ui-*`).
import {
type ButtonHTMLAttributes,
createElement as h,
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
useEffect,
useId,
useRef,
useState,
} from 'react';
/** Une clases ignorando vacíos. */
export const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(' ');
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'default' | 'primary' | 'danger' | 'ghost'; size?: 'md' | 'sm'; loading?: boolean };
export function Button({ variant = 'default', size = 'md', loading, className, children, type, ...rest }: ButtonProps): ReactNode {
return h(
'button',
{ type: type ?? 'button', className: cx('ui-button', variant !== 'default' && `ui-button--${variant}`, size === 'sm' && 'ui-button--sm', className), 'aria-busy': loading || undefined, ...rest, disabled: rest.disabled || loading },
loading && h('span', { className: 'ui-spinner', 'aria-hidden': true }),
children,
);
}
type FieldWrap = { label: ReactNode; help?: ReactNode; error?: ReactNode; required?: boolean; hideLabel?: boolean };
/** Etiqueta + control + ayuda + error, enlazados con `aria-describedby`. */
export type FieldIds = { id: string; describedBy?: string; invalid: boolean };
export function Field(props: FieldWrap & { render: (ids: FieldIds) => ReactNode }): ReactNode {
const id = useId();
const helpId = props.help ? `${id}-help` : undefined;
const errorId = props.error ? `${id}-error` : undefined;
const describedBy = [helpId, errorId].filter(Boolean).join(' ') || undefined;
return h(
'div',
{ className: 'ui-field' },
h('label', { htmlFor: id, className: cx('ui-field__label', props.hideLabel && 'ui-visually-hidden') }, props.label, props.required && h('span', { 'aria-hidden': true }, ' *')),
props.render({ id, describedBy, invalid: !!props.error }),
props.help && h('span', { id: helpId, className: 'ui-field__help' }, props.help),
props.error && h('span', { id: errorId, className: 'ui-field__error', role: 'alert' }, props.error),
);
}
export function TextField(props: FieldWrap & Omit<InputHTMLAttributes<HTMLInputElement>, 'id'>): ReactNode {
const { label, help, error, hideLabel, className, ...input } = props;
return h(Field, {
label,
help,
error,
hideLabel,
required: input.required,
render: ({ id, describedBy, invalid }: FieldIds) =>
h('input', { id, 'aria-describedby': describedBy, 'aria-invalid': invalid || undefined, className: cx('ui-input', className), ...input }),
});
}
export function TextareaField(props: FieldWrap & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'id'>): ReactNode {
const { label, help, error, hideLabel, className, ...input } = props;
return h(Field, {
label,
help,
error,
hideLabel,
required: input.required,
render: ({ id, describedBy, invalid }: FieldIds) =>
h('textarea', { id, rows: 4, 'aria-describedby': describedBy, 'aria-invalid': invalid || undefined, className: cx('ui-textarea', className), ...input }),
});
}
export function SelectField(props: FieldWrap & Omit<SelectHTMLAttributes<HTMLSelectElement>, 'id'> & { options: Array<{ value: string; label: string }>; placeholder?: string }): ReactNode {
const { label, help, error, hideLabel, className, options, placeholder, ...select } = props;
return h(Field, {
label,
help,
error,
hideLabel,
required: select.required,
render: ({ id, describedBy, invalid }: FieldIds) =>
h(
'select',
{ id, 'aria-describedby': describedBy, 'aria-invalid': invalid || undefined, className: cx('ui-select', className), ...select },
placeholder !== undefined && h('option', { value: '' }, placeholder),
options.map((o) => h('option', { key: o.value, value: o.value }, o.label)),
),
});
}
export function Checkbox(props: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & { label: ReactNode }): ReactNode {
const { label, className, ...input } = props;
return h('label', { className: cx('ui-checkbox', className) }, h('input', { type: 'checkbox', ...input }), label);
}
export const Card = (p: { children?: ReactNode; className?: string; as?: 'div' | 'section' | 'article' }) => h(p.as ?? 'div', { className: cx('ui-card', p.className) }, p.children);
export const Badge = (p: { children?: ReactNode; tone?: 'neutral' | 'success' | 'warning' | 'danger' }) => h('span', { className: cx('ui-badge', p.tone && p.tone !== 'neutral' && `ui-badge--${p.tone}`) }, p.children);
export const Alert = (p: { children?: ReactNode; tone?: 'info' | 'error' | 'success' | 'warning'; title?: ReactNode }) =>
h('div', { role: p.tone === 'error' ? 'alert' : 'status', className: cx('ui-alert', p.tone && p.tone !== 'info' && `ui-alert--${p.tone}`) }, p.title && h('strong', null, p.title, ' '), p.children);
export const Skeleton = (p: { width?: string; height?: string }) => h('div', { className: 'ui-skeleton', 'aria-hidden': true, style: { width: p.width ?? '100%', height: p.height ?? '1rem' } });
export const Spinner = (p: { label?: string }) => h('span', { role: 'status' }, h('span', { className: 'ui-spinner', 'aria-hidden': true }), h('span', { className: 'ui-visually-hidden' }, p.label ?? 'Loading'));
export const VisuallyHidden = (p: { children?: ReactNode }) => h('span', { className: 'ui-visually-hidden' }, p.children);
export type Column<T> = { key: string; header: ReactNode; cell: (row: T) => ReactNode; sortable?: boolean };
/** Tabla de datos con encabezados `scope="col"`, orden accesible (`aria-sort`) y estado vacío. */
export function DataTable<T>(p: {
caption: ReactNode;
columns: Column<T>[];
rows: T[];
rowKey: (row: T) => string;
sort?: { key: string; dir: 'asc' | 'desc' };
onSort?: (key: string) => void;
empty?: ReactNode;
}): ReactNode {
return h(
'table',
{ className: 'ui-table' },
h('caption', { className: 'ui-visually-hidden' }, p.caption),
h(
'thead',
null,
h(
'tr',
null,
p.columns.map((c) => {
const sorted = p.sort?.key === c.key ? (p.sort.dir === 'asc' ? 'ascending' : 'descending') : undefined;
return h('th', { key: c.key, scope: 'col', 'aria-sort': c.sortable ? (sorted ?? 'none') : undefined }, c.sortable && p.onSort ? h('button', { type: 'button', className: 'ui-button ui-button--ghost ui-button--sm', onClick: () => p.onSort!(c.key) }, c.header) : c.header);
}),
),
),
h(
'tbody',
null,
p.rows.length
? p.rows.map((r) => h('tr', { key: p.rowKey(r) }, p.columns.map((c) => h('td', { key: c.key }, c.cell(r)))))
: h('tr', null, h('td', { colSpan: p.columns.length }, p.empty ?? '—')),
),
);
}
/** Paginación con `nav` etiquetada y página actual marcada con `aria-current`. */
export function Pagination(p: { page: number; pages: number; onPage: (page: number) => void; labels?: { nav?: string; prev?: string; next?: string; page?: (n: number) => string } }): ReactNode {
if (p.pages <= 1) return null;
const l = { nav: 'Pagination', prev: 'Previous', next: 'Next', page: (n: number) => `Page ${n}`, ...p.labels };
const window = [...new Set([1, p.page - 1, p.page, p.page + 1, p.pages])].filter((n) => n >= 1 && n <= p.pages).sort((a, b) => a - b);
return h(
'nav',
{ 'aria-label': l.nav, className: 'ui-pagination' },
h(Button, { size: 'sm', disabled: p.page <= 1, onClick: () => p.onPage(p.page - 1) }, l.prev),
window.map((n) => h(Button, { key: n, size: 'sm', variant: n === p.page ? 'primary' : 'default', 'aria-current': n === p.page ? 'page' : undefined, 'aria-label': l.page(n), onClick: () => p.onPage(n) }, String(n))),
h(Button, { size: 'sm', disabled: p.page >= p.pages, onClick: () => p.onPage(p.page + 1) }, l.next),
);
}
/** Diálogo modal nativo (`<dialog>`): foco atrapado, Escape y backdrop los gestiona el navegador. */
export function Dialog(p: { open: boolean; onClose: () => void; title: ReactNode; children?: ReactNode; footer?: ReactNode }): ReactNode {
const ref = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
const d = ref.current;
if (!d) return;
if (p.open && !d.open) d.showModal?.();
if (!p.open && d.open) d.close();
}, [p.open]);
return h(
'dialog',
{ ref, className: 'ui-dialog', 'aria-labelledby': titleId, onClose: p.onClose, onCancel: p.onClose },
h('h2', { id: titleId }, p.title),
p.children,
p.footer && h('div', { className: 'ui-dialog__footer' }, p.footer),
);
}
/** Pestañas WAI-ARIA con flechas izquierda/derecha. */
export function Tabs(p: { label: string; tabs: Array<{ id: string; label: ReactNode; content: ReactNode }>; initial?: string }): ReactNode {
const base = useId();
const [active, setActive] = useState(p.initial ?? p.tabs[0]?.id);
const index = Math.max(0, p.tabs.findIndex((t) => t.id === active));
const move = (delta: number) => {
const next = p.tabs[(index + delta + p.tabs.length) % p.tabs.length]!;
setActive(next.id);
(globalThis.document?.getElementById(`${base}-tab-${next.id}`) as HTMLElement | null)?.focus();
};
return h(
'div',
{ className: 'ui-tabs' },
h(
'div',
{ role: 'tablist', 'aria-label': p.label, className: 'ui-tabs__list' },
p.tabs.map((t) =>
h(
'button',
{
key: t.id,
id: `${base}-tab-${t.id}`,
role: 'tab',
type: 'button',
className: 'ui-tabs__tab',
'aria-selected': t.id === active,
'aria-controls': `${base}-panel-${t.id}`,
tabIndex: t.id === active ? 0 : -1,
onClick: () => setActive(t.id),
onKeyDown: (e: { key: string; preventDefault(): void }) => {
if (e.key === 'ArrowRight') (e.preventDefault(), move(1));
if (e.key === 'ArrowLeft') (e.preventDefault(), move(-1));
},
},
t.label,
),
),
),
p.tabs.map((t) => h('div', { key: t.id, id: `${base}-panel-${t.id}`, role: 'tabpanel', 'aria-labelledby': `${base}-tab-${t.id}`, hidden: t.id !== active, tabIndex: 0 }, t.content)),
);
}
export type Toast = { id: number; tone: 'success' | 'error' | 'info'; message: ReactNode };
/** Región `aria-live` para avisos; usa `useToasts()` para añadirlos. */
export function Toasts(p: { toasts: Toast[]; onDismiss: (id: number) => void; dismissLabel?: string }): ReactNode {
return h(
'div',
{ className: 'ui-toasts', 'aria-live': 'polite', role: 'status' },
p.toasts.map((t) => h('div', { key: t.id, className: cx('ui-alert', t.tone !== 'info' && `ui-alert--${t.tone}`) }, t.message, ' ', h(Button, { size: 'sm', variant: 'ghost', onClick: () => p.onDismiss(t.id), 'aria-label': p.dismissLabel ?? 'Dismiss' }, '×'))),
);
}
export function useToasts(timeoutMs = 5000) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = (id: number) => setToasts((ts) => ts.filter((t) => t.id !== id));
const push = (tone: Toast['tone'], message: ReactNode) => {
const id = Date.now() + Math.random();
setToasts((ts) => [...ts, { id, tone, message }]);
if (timeoutMs > 0) setTimeout(() => dismiss(id), timeoutMs);
};
return { toasts, push, dismiss };
}
Este pacote não declara servidores MCP.
| Versão | Commit | Publicado | Análise |
|---|---|---|---|
| 1.0.1 | 9955ea1 | há 5 horas | análise aprovada |
- genpm
- nenhum
- proposto
- O GenPM propõe o comando npm e só o executa se você disser sim.
- Usado por (2)
- @core/admin ^1.0.0@core/blocks ^1.0.0
- análise
- análise aprovada · 0 achados
- commit
- v1.0.1 → 9955ea1d72f43b5cecdc5f3d244507c8bc2e4bc9 · verificado após o download
- scripts
- Nenhum. O GenPM nunca executa código de pacotes.
- licença
- MIT
- Qualidade
- 100/100
- Licença reconhecidacumprido
- AGENTS.md explica o propósitocumprido
- AGENTS.md tem passos de integraçãocumprido
- AGENTS.md lista convenções ou proibiçõescumprido
- Inclui testescumprido
- Escaneamento de segurança aprovadocumprido
- Publicado nos últimos 6 mesescumprido
- Publicador verificadocumprido
- Resumo e palavras-chavecumprido
- denúncia
- Viu algo errado?