diff --git a/src/uix/eidos/components/palabras/palabras-panel.svelte b/src/uix/eidos/components/palabras/palabras-panel.svelte
index 9e76b8a12..c57b99c03 100644
--- a/src/uix/eidos/components/palabras/palabras-panel.svelte
+++ b/src/uix/eidos/components/palabras/palabras-panel.svelte
@@ -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)}
/>
diff --git a/src/uix/eidos/components/palabras/palabras-segmented.svelte b/src/uix/eidos/components/palabras/palabras-segmented.svelte
index 3bb8d23f9..dfcebc3b1 100644
--- a/src/uix/eidos/components/palabras/palabras-segmented.svelte
+++ b/src/uix/eidos/components/palabras/palabras-segmented.svelte
@@ -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();
@@ -38,6 +43,7 @@
size="xs"
attached
{disabled}
+ {deselectable}
aria-label={ariaLabel}
value={value == null ? [] : [value]}
onValueChange={(v) => onValueChange(v[0])}
diff --git a/src/uix/eidos/components/palabras/palabras.css b/src/uix/eidos/components/palabras/palabras.css
index 177a6de7f..aaad4c1a2 100644
--- a/src/uix/eidos/components/palabras/palabras.css
+++ b/src/uix/eidos/components/palabras/palabras.css
@@ -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. */
diff --git a/src/uix/eidos/components/palabras/panel-schema.ts b/src/uix/eidos/components/palabras/panel-schema.ts
index da714e26f..550403921 100644
--- a/src/uix/eidos/components/palabras/panel-schema.ts
+++ b/src/uix/eidos/components/palabras/panel-schema.ts
@@ -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.
diff --git a/src/uix/eidos/components/palabras/types.ts b/src/uix/eidos/components/palabras/types.ts
index ae597353e..b95f3ef21 100644
--- a/src/uix/eidos/components/palabras/types.ts
+++ b/src/uix/eidos/components/palabras/types.ts
@@ -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;
}
diff --git a/src/uix/eidos/components/toggle-group/README.md b/src/uix/eidos/components/toggle-group/README.md
index 84400f702..5526d4a31 100644
--- a/src/uix/eidos/components/toggle-group/README.md
+++ b/src/uix/eidos/components/toggle-group/README.md
@@ -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.
diff --git a/src/uix/soma/components/toggle-group/README.md b/src/uix/soma/components/toggle-group/README.md
index 8574741de..848031aa7 100644
--- a/src/uix/soma/components/toggle-group/README.md
+++ b/src/uix/soma/components/toggle-group/README.md
@@ -105,6 +105,21 @@ Arrow keys move focus only. Pressing the item (click, Enter, Space) toggles its
S
```
+### 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
+
+ H1
+ H2
+ H3
+
+```
+
## 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 |
diff --git a/src/uix/soma/components/toggle-group/components/toggle-group.svelte b/src/uix/soma/components/toggle-group/components/toggle-group.svelte
index 7bfd398e7..793c986f6 100644
--- a/src/uix/soma/components/toggle-group/components/toggle-group.svelte
+++ b/src/uix/soma/components/toggle-group/components/toggle-group.svelte
@@ -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) => {
diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts
index c441f24ec..4e6583c82 100644
--- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts
+++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.test.ts
@@ -57,6 +57,7 @@ function groupOpts(root = createRoot()) {
ref: state(root),
value: state([]),
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();
diff --git a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
index 90117e7d7..2a6a6f8e2 100644
--- a/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
+++ b/src/uix/soma/components/toggle-group/toggle-group-provider.svelte.ts
@@ -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];
}
diff --git a/src/uix/soma/components/toggle-group/types.ts b/src/uix/soma/components/toggle-group/types.ts
index 591804e73..c5feb1f89 100644
--- a/src/uix/soma/components/toggle-group/types.ts
+++ b/src/uix/soma/components/toggle-group/types.ts
@@ -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. */
diff --git a/src/uix/soma/components/words/engine/blocks/built-ins.ts b/src/uix/soma/components/words/engine/blocks/built-ins.ts
index 75cca2f0b..f6475ac96 100644
--- a/src/uix/soma/components/words/engine/blocks/built-ins.ts
+++ b/src/uix/soma/components/words/engine/blocks/built-ins.ts
@@ -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)}`
);
}
},
diff --git a/src/uix/soma/components/words/engine/operations/parse-html.svelte.test.ts b/src/uix/soma/components/words/engine/operations/parse-html.svelte.test.ts
index c4f595871..04412d042 100644
--- a/src/uix/soma/components/words/engine/operations/parse-html.svelte.test.ts
+++ b/src/uix/soma/components/words/engine/operations/parse-html.svelte.test.ts
@@ -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('One
Two
Five
');
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)', () => {
diff --git a/src/uix/soma/components/words/engine/operations/parse-html.ts b/src/uix/soma/components/words/engine/operations/parse-html.ts
index 4d1bc68b5..0ec9e50eb 100644
--- a/src/uix/soma/components/words/engine/operations/parse-html.ts
+++ b/src/uix/soma/components/words/engine/operations/parse-html.ts
@@ -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. */
diff --git a/src/uix/soma/components/words/engine/render.ts b/src/uix/soma/components/words/engine/render.ts
index 80bcea972..e313189c0 100644
--- a/src/uix/soma/components/words/engine/render.ts
+++ b/src/uix/soma/components/words/engine/render.ts
@@ -90,6 +90,9 @@ export type WordsRenderTag =
| 'h1'
| 'h2'
| 'h3'
+ | 'h4'
+ | 'h5'
+ | 'h6'
| 'li'
| 'ol'
| 'p'
diff --git a/src/uix/soma/components/words/engine/types.ts b/src/uix/soma/components/words/engine/types.ts
index 0ce44e8bf..a6874007d 100644
--- a/src/uix/soma/components/words/engine/types.ts
+++ b/src/uix/soma/components/words/engine/types.ts
@@ -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',
diff --git a/src/uix/soma/components/words/engine/validate.test.ts b/src/uix/soma/components/words/engine/validate.test.ts
index 5a1ba0e59..dc69ea8fa 100644
--- a/src/uix/soma/components/words/engine/validate.test.ts
+++ b/src/uix/soma/components/words/engine/validate.test.ts
@@ -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', () => {
diff --git a/src/uix/soma/components/words/words-provider.svelte.ts b/src/uix/soma/components/words/words-provider.svelte.ts
index 7ee94d085..4dbe1d0e3 100644
--- a/src/uix/soma/components/words/words-provider.svelte.ts
+++ b/src/uix/soma/components/words/words-provider.svelte.ts
@@ -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