feat(uix): heading levels H1-H6 + ToggleGroup deselectable

Words: lift heading level cap 1-3 -> 1-6 (engine type, WORDS_HEADING_LEVELS,
render tag union, validate message, parse-html no longer clamps h4-h6, provider
unions). Palabras panel: Nivel toggle H1...H6 wired to the theme's --style-h{n}
scale; level is a required selection.

ToggleGroup: new `deselectable` prop (default true). In single mode,
`deselectable={false}` requires a selection - re-pressing the active item is a
no-op (radio-like, like Ark/Bits). Soma provider + types + docs; eidos forwards;
palabras uses it for the heading Nivel toggle. Provider test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 7da7285d94
commit 5e98cc3eb3

@ -253,6 +253,7 @@
value={pv == null ? undefined : String(pv)}
options={field.options ?? []}
{disabled}
deselectable={!field.required}
ariaLabel={field.label}
onValueChange={(s) => onPills(field, s)}
/>

@ -17,6 +17,7 @@
value,
options,
disabled = false,
deselectable = true,
ariaLabel,
onValueChange
}: {
@ -24,9 +25,13 @@
value: string | undefined;
readonly options: readonly PalabrasFieldOption[];
disabled?: boolean;
/** When `false`, a selection is REQUIRED: re-pressing the active option does
* not clear it (radio-like). @default true */
deselectable?: boolean;
ariaLabel?: string;
/** Fired with the picked option's stringified value, or `undefined` when the
* active item is re-clicked (deselected) — lets the field clear / restore. */
* active item is re-clicked (deselected, only when `deselectable`) — lets the
* field clear / restore. */
onValueChange: (value: string | undefined) => void;
} = $props();
</script>
@ -38,6 +43,7 @@
size="xs"
attached
{disabled}
{deselectable}
aria-label={ariaLabel}
value={value == null ? [] : [value]}
onValueChange={(v) => onValueChange(v[0])}

@ -177,9 +177,54 @@
[data-palabras-doc] [data-words-content][data-archetype]:focus-visible {
box-shadow: none;
}
[data-palabras-doc] [data-words-content] :where(h1, h2, h3, p) {
[data-palabras-doc] [data-words-content] :where(h1, h2, h3, h4, h5, h6, p) {
margin-block: 0 0.6em;
}
/* Editor headings adopt the theme's named heading scale (`--style-h{n}`), so the
six levels render with the canonical typography (the size hierarchy h1 → h6).
Per-block overrides (inline font-size / colour from the panel) still win. */
[data-palabras-doc] [data-words-content] h1 {
font-family: var(--style-h1-font-family);
font-size: var(--style-h1-font-size);
font-weight: var(--style-h1-font-weight);
line-height: var(--style-h1-line-height);
letter-spacing: var(--style-h1-letter-spacing);
}
[data-palabras-doc] [data-words-content] h2 {
font-family: var(--style-h2-font-family);
font-size: var(--style-h2-font-size);
font-weight: var(--style-h2-font-weight);
line-height: var(--style-h2-line-height);
letter-spacing: var(--style-h2-letter-spacing);
}
[data-palabras-doc] [data-words-content] h3 {
font-family: var(--style-h3-font-family);
font-size: var(--style-h3-font-size);
font-weight: var(--style-h3-font-weight);
line-height: var(--style-h3-line-height);
letter-spacing: var(--style-h3-letter-spacing);
}
[data-palabras-doc] [data-words-content] h4 {
font-family: var(--style-h4-font-family);
font-size: var(--style-h4-font-size);
font-weight: var(--style-h4-font-weight);
line-height: var(--style-h4-line-height);
letter-spacing: var(--style-h4-letter-spacing);
}
[data-palabras-doc] [data-words-content] h5 {
font-family: var(--style-h5-font-family);
font-size: var(--style-h5-font-size);
font-weight: var(--style-h5-font-weight);
line-height: var(--style-h5-line-height);
letter-spacing: var(--style-h5-letter-spacing);
}
[data-palabras-doc] [data-words-content] h6 {
font-family: var(--style-h6-font-family);
font-size: var(--style-h6-font-size);
font-weight: var(--style-h6-font-weight);
line-height: var(--style-h6-line-height);
letter-spacing: var(--style-h6-letter-spacing);
}
/* Selection marker for the active/selected block — dotted (NOT the solid focus
ring, which is suppressed above). Stamped by WordsActivate on the active
block element. */

@ -24,10 +24,15 @@ const heading: PalabrasBlockSchema = {
key: 'level',
type: 'pills',
label: 'Nivel',
// A heading always has a level — the toggle can't be cleared (H1 default).
required: true,
options: [
{ value: 1, label: 'Sección' },
{ value: 2, label: 'Subsección' },
{ value: 3, label: 'Apartado' }
{ value: 1, label: 'H1' },
{ value: 2, label: 'H2' },
{ value: 3, label: 'H3' },
{ value: 4, label: 'H4' },
{ value: 5, label: 'H5' },
{ value: 6, label: 'H6' }
]
},
// `textarea` edits the block's TEXT (its inline `children`), not a prop.

@ -104,6 +104,11 @@ export interface PalabrasFieldDef {
readonly clearWhenZero?: boolean;
/** Choices for `pills` / `select`. The `value` is written raw to the prop. */
readonly options?: readonly PalabrasFieldOption[];
/** `pills` field: the selection is REQUIRED — re-pressing the active option does
* NOT clear it (the control passes `deselectable={false}` to the ToggleGroup).
* For props that must always carry a value, e.g. a heading's `level` (a heading
* is always some level; H1 by default). */
readonly required?: boolean;
/** Read-only display (e.g. an `'Auto'` chip). */
readonly readOnly?: boolean;
}

@ -46,10 +46,13 @@ buttons that share a single selection model — `type="single"`
## Headless props (forwarded to soma)
`type`, `value`, `onValueChange`, `disabled`, `orientation`,
`loop`, `dir`, `aria-label`, `aria-labelledby`. See
`type`, `deselectable`, `value`, `onValueChange`, `disabled`,
`orientation`, `loop`, `dir`, `aria-label`, `aria-labelledby`. See
`src/uix/soma/components/toggle-group/types.ts`.
> `deselectable={false}` makes `single` mode require a selection — re-pressing
> the active item is a no-op and it stays pressed (radio-like). Defaults `true`.
> `aria-label` or `aria-labelledby` is REQUIRED. `role="group"`
> needs an accessible name per APG.

@ -105,6 +105,21 @@ Arrow keys move focus only. Pressing the item (click, Enter, Space) toggles its
<ToggleGroup.Item value="strikethrough" disabled>S</ToggleGroup.Item>
```
### Required selection (single, non-deselectable)
By default `single` mode lets the user clear the selection by re-pressing the
active item. Pass `deselectable={false}` to require a selection — re-pressing the
active item is a no-op and it stays pressed (radio-like). No effect in `multiple`
mode.
```svelte
<ToggleGroup.Provider bind:value type="single" deselectable={false}>
<ToggleGroup.Item value="h1">H1</ToggleGroup.Item>
<ToggleGroup.Item value="h2">H2</ToggleGroup.Item>
<ToggleGroup.Item value="h3">H3</ToggleGroup.Item>
</ToggleGroup.Provider>
```
## Comparison with reference libraries
| Feature | Radix | Ark | Bits | Soma | Decision |
@ -112,7 +127,7 @@ Arrow keys move focus only. Pressing the item (click, Enter, Space) toggles its
| Standalone Toggle | Yes | Yes | Yes | No (air-native) | Single button, no composition needed |
| ToggleGroup | Root + Item | Root + Item | Root + Item | Provider + Item | Standard pattern |
| type single/multiple | `type` prop | `multiple` bool | `type` prop | `type` prop | Matches Radix/Bits |
| Deselectable (single) | Implicit | `deselectable` prop | Implicit | Always deselectable | Single mode always allows empty |
| Deselectable (single) | Implicit | `deselectable` prop | Implicit | `deselectable` prop | Defaults `true`; `deselectable={false}` requires a selection (à la Ark/Bits) |
| Roving focus | Default on | Default on | Default on | Always on | Core behavior |
| Loop | Not exposed | `loopFocus` | `loop` | `loop` | Matches Bits |
| Orientation | Yes | Yes | Yes | Yes | Standard |

@ -18,6 +18,7 @@
ref = $bindable(null),
id = createId(uid, 'toggle-group'),
type = 'single',
deselectable = true,
value = $bindable([]),
onValueChange = () => {},
disabled = false,
@ -52,6 +53,7 @@
(v) => (ref = v)
),
type: readableActive(() => type),
deselectable: readableActive(() => deselectable),
value: writableActive(
() => value,
(v) => {

@ -57,6 +57,7 @@ function groupOpts(root = createRoot()) {
ref: state<HTMLElement | null>(root),
value: state<string[]>([]),
type: state<'single' | 'multiple'>('single'),
deselectable: state(true),
disabled: state(false),
orientation: state<'horizontal' | 'vertical'>('horizontal'),
loop: state(true),
@ -127,6 +128,26 @@ describe('ToggleGroupProvider', () => {
dom.dispose();
});
it('with deselectable=false, re-pressing the active item keeps it selected', () => {
const { dom } = installSomaHarness();
const opts = groupOpts();
opts.deselectable.current = false;
const { result: provider, cleanup } = withEffectRoot(() => ToggleGroupProvider.create(opts));
provider.toggleItem('h1');
expect(opts.value.current).toEqual(['h1']);
// Re-pressing the active item is a no-op — a selection is required.
provider.toggleItem('h1');
expect(opts.value.current).toEqual(['h1']);
// Switching to a different item still works.
provider.toggleItem('h2');
expect(opts.value.current).toEqual(['h2']);
cleanup();
dom.dispose();
});
it('supports multiple selection mode', () => {
const { dom } = installSomaHarness();
const opts = groupOpts();

@ -19,6 +19,7 @@ interface ToggleGroupOpts
StateProps<{ value: string[] }>,
ActiveProps<{
type: ToggleGroupType;
deselectable: boolean;
disabled: boolean;
orientation: Orientation;
loop: boolean;
@ -72,7 +73,15 @@ export class ToggleGroupProvider {
let next: string[];
if (this.opts.type.current === 'single') {
next = isPressed ? [] : [value];
if (isPressed) {
// Re-pressing the active item deselects it (→ empty) only when
// `deselectable`; otherwise it stays pressed (radio-like — a
// selection is required) and this is a no-op.
if (!this.opts.deselectable.current) return;
next = [];
} else {
next = [value];
}
} else {
next = isPressed ? current.filter((v) => v !== value) : [...current, value];
}

@ -7,11 +7,18 @@ export type ToggleGroupProps = WithChild<{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/**
* 'single' allows one item pressed at a time (deselectable).
* 'multiple' allows any number of items pressed.
* 'single' allows one item pressed at a time (deselectable by default —
* see `deselectable`). 'multiple' allows any number of items pressed.
* @default 'single'
*/
type?: ToggleGroupType;
/**
* In `single` mode, whether re-pressing the active item deselects it
* (clearing the value to empty). Set `false` to require a selection: re-pressing
* the active item is a no-op and it stays pressed (radio-like). No effect in
* `multiple` mode. @default true
*/
deselectable?: boolean;
/** Array of pressed item values. Bindable. @default [] */
value?: string[];
/** Callback fired when value changes. */

@ -144,7 +144,7 @@ const headingSpec: WordsBlockSpec = {
ctx.error(
`${ctx.path}/level`,
'invalid-enum-value',
`heading.level must be 1, 2, or 3; got ${JSON.stringify(block.level)}`
`heading.level must be 1–6; got ${JSON.stringify(block.level)}`
);
}
},

@ -30,10 +30,10 @@ describe('parseWordsHtml', () => {
expect(bold && marksOf(bold)).toContain('bold');
});
it('headings (h1-h3 direct, h4+ clamp to 3)', () => {
it('headings (h1-h6 map directly)', () => {
const blocks = parse('<h1>One</h1><h2>Two</h2><h5>Five</h5>');
expect(blocks.map((b) => b.type)).toEqual(['heading', 'heading', 'heading']);
expect(blocks.map((b) => (b.type === 'heading' ? b.level : 0))).toEqual([1, 2, 3]);
expect(blocks.map((b) => (b.type === 'heading' ? b.level : 0))).toEqual([1, 2, 5]);
});
it('nested marks compose (italic + bold)', () => {

@ -279,9 +279,9 @@ function marksForElement(
// ── Small helpers ──────────────────────────────────────────────────────────
function headingLevel(tag: string): WordsHeadingLevel {
// h1-h3 map directly; h4-h6 collapse to h3 (the model caps at 3).
// h1-h6 map directly; clamp anything out of range into [1, 6].
const n = Number(tag.slice(1));
return (n <= 1 ? 1 : n === 2 ? 2 : 3) as WordsHeadingLevel;
return Math.min(6, Math.max(1, n || 1)) as WordsHeadingLevel;
}
/** Collapse runs of whitespace (incl. newlines) to single spaces, HTML-style. */

@ -90,6 +90,9 @@ export type WordsRenderTag =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'li'
| 'ol'
| 'p'

@ -158,7 +158,7 @@ export type WordsInline = WordsText | WordsLink;
// ── Intrinsic enums ───────────────────────────────────────────────────────────
export type WordsHeadingLevel = 1 | 2 | 3;
export type WordsHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
export type WordsListKind = 'ordered' | 'unordered' | 'check';
export type WordsImageAlign = 'left' | 'center' | 'right';
/** How the image fills its frame: fill (cover/crop), fit (contain/letterbox),
@ -426,7 +426,9 @@ export const WORDS_BLOCK_TYPES = [
'callout'
] as const satisfies readonly WordsBlockType[];
export const WORDS_HEADING_LEVELS = [1, 2, 3] as const satisfies readonly WordsHeadingLevel[];
export const WORDS_HEADING_LEVELS = [
1, 2, 3, 4, 5, 6
] as const satisfies readonly WordsHeadingLevel[];
export const WORDS_ALIGNS = [
'left',
'center',

@ -307,12 +307,20 @@ describe('id uniqueness', () => {
// ── Per-block-type semantic rules ────────────────────────────────────────
describe('heading', () => {
it('rejects level outside 1..3', () => {
it('rejects level outside 1..6', () => {
const r = validateWordsDocument(
doc({ type: 'heading', level: 4 as never, children: [{ type: 'text', text: 'h' }] })
doc({ type: 'heading', level: 7 as never, children: [{ type: 'text', text: 'h' }] })
);
expect(r.valid).toBe(false);
});
it('accepts levels 1..6', () => {
for (const level of [1, 2, 3, 4, 5, 6] as const) {
const r = validateWordsDocument(
doc({ type: 'heading', level, children: [{ type: 'text', text: 'h' }] })
);
expect(r.valid).toBe(true);
}
});
});
describe('list', () => {

@ -126,7 +126,7 @@ interface WordsCommandButtonOpts
WithRefOpts,
ActiveProps<{
command: WordsCommandName;
level: 1 | 2 | 3 | undefined;
level: 1 | 2 | 3 | 4 | 5 | 6 | undefined;
listKind: 'ordered' | 'unordered' | 'check' | undefined;
}> {}
interface WordsLinkEditorOpts
@ -710,7 +710,7 @@ export class WordsProvider {
runCommandName(
name: WordsCommandName,
opts: { target?: HTMLElement; level?: 1 | 2 | 3; replacement?: string } = {}
opts: { target?: HTMLElement; level?: 1 | 2 | 3 | 4 | 5 | 6; replacement?: string } = {}
) {
if (name === 'undo') {
this.undo(opts.target);
@ -2060,7 +2060,10 @@ export class WordsProvider {
};
}
private commandFromName(name: WordsCommandName, level?: 1 | 2 | 3): WordsCommand | undefined {
private commandFromName(
name: WordsCommandName,
level?: 1 | 2 | 3 | 4 | 5 | 6
): WordsCommand | undefined {
// Parametric color marks come through as `color:#hex` /
// `bgcolor:#hex` (empty value clears). Build the structured V2
// mark — the engine REPLACES same-type marks, and an empty

Loading…
Cancel
Save

Powered by TurnKey Linux.