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