Skip to content

Commit 1536dcd

Browse files
authored
fix(settings/ui): id-keyed dependencies, RadioImageCard layout, rich-text caret (#99)
* fix(radio-group): enhance styling for RadioImageCard component * fix(settings): resolve id-keyed dependencies regardless of values keying evaluateDependencies looked up values[dep.key] directly. When the flat values map is keyed by the reconstructed dot-path instead of the plain field id, that lookup misses and every dependency-gated field collapses to hidden. Add layered resolution: direct id, then optional idIndex, then last dot-path-segment match. Makes the previously-dormant idIndex param useful and keeps plain-id dependencies working under either keying. * style(radio-group): remove RadioImageCard body padding for flush image * fix(rich-text): stop caret jumping to start on every keystroke The contentEditable was bound with dangerouslySetInnerHTML={value}, so React re-applied innerHTML on each keystroke-driven re-render and collapsed the selection to the start — every typed char appeared to prepend (reversed/RTL effect). Make it uncontrolled: drop dangerouslySetInnerHTML and only sync the DOM from value/defaultValue when content differs and the editor isn't focused. * style(radio-group): make RadioImageCard illustration fill card width Image rendered at intrinsic size and was centered, leaving asymmetric whitespace that read as padding. Make the image block w-full and drop the centering wrapper so the illustration spans the full card width flush. * fix(radio): make RadioImageCard illustration flush to card edges The base FieldLabel injects `*:data-[slot=field]:p-3` which targets the child Field with higher specificity than a `p-0!` on the Field itself, so under the global !important strategy it could not be overridden there. Neutralise it at the root via the same child-targeting utility (`*:data-[slot=field]:p-0`); twMerge collapses p-3 -> p-0 so the illustration sits flush. * fix(radio): pad RadioImageCard content to match the card header FieldContent now uses the same px-5 py-4 padding as the header row, so the illustration is inset consistently with the title instead of bleeding flush.
1 parent 942227e commit 1536dcd

3 files changed

Lines changed: 46 additions & 20 deletions

File tree

src/components/settings/settings-formatter.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -334,12 +334,13 @@ export function buildIdIndex(
334334
/**
335335
* Evaluates whether a field should be displayed based on its dependencies.
336336
*
337-
* Dependency keys are plain field ids (post-dependency_key cleanup). The
338-
* value is read directly from the flat `values` map keyed by id.
339-
*
340-
* `idIndex` is kept as an optional parameter for backwards compatibility with
341-
* call sites that still pass it; it's unused now that dependency_key is
342-
* gone and `dep.key` always equals the field id.
337+
* Dependency keys are authored as plain field ids. The flat `values` map is
338+
* keyed by field id (current contract), so a direct lookup normally resolves.
339+
* For resilience against older/alternate payloads that key `values` by the
340+
* reconstructed dot-path (e.g. `page.section.field`), the lookup falls back to:
341+
* 1. an explicit `idIndex` mapping (field id → stored key), when supplied; then
342+
* 2. matching a stored key whose last dot-path segment equals the field id.
343+
* This keeps id-keyed dependencies working regardless of how `values` is keyed.
343344
*/
344345
export function evaluateDependencies(
345346
element: SettingsElement,
@@ -353,7 +354,18 @@ export function evaluateDependencies(
353354
return element.dependencies.every((dep) => {
354355
if (!dep.key) return true;
355356

356-
const currentValue = values[dep.key];
357+
let currentValue = values[dep.key];
358+
if (currentValue === undefined) {
359+
const mapped = idIndex?.[dep.key];
360+
if (mapped !== undefined && values[mapped] !== undefined) {
361+
currentValue = values[mapped];
362+
} else {
363+
const match = Object.keys(values).find(
364+
(k) => k === dep.key || k.split('.').pop() === dep.key
365+
);
366+
if (match !== undefined) currentValue = values[match];
367+
}
368+
}
357369

358370
const comparison = dep.comparison || '==';
359371
const expectedValue = dep.value;

src/components/ui/radio-group.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,36 +143,41 @@ function RadioImageCard({
143143
return (
144144
<FieldGroup className={cn(disabled && "opacity-50")}>
145145
<FieldLabel className={cn(
146-
"transition-colors has-data-checked:bg-transparent dark:has-data-checked:bg-transparent p-0 group cursor-pointer group",
146+
// The base FieldLabel injects `*:data-[slot=field]:p-3`, which targets the
147+
// child Field with higher specificity than a `p-0!` on the Field itself, so
148+
// it can't be overridden there (esp. under the global !important strategy).
149+
// Neutralise it at the root via the same child-targeting utility — twMerge
150+
// collapses p-3 -> p-0 so the illustration sits flush to the card edges.
151+
"transition-colors has-data-checked:bg-transparent dark:has-data-checked:bg-transparent p-0 *:data-[slot=field]:p-0 group cursor-pointer rounded-xl overflow-hidden",
147152
currentValue === props.value && 'border-primary!',
148153
!disabled && "hover:border-primary"
149154
)}>
150155
<Field
151156
orientation={orientation}
152157
data-disabled={disabled}
153-
className="flex flex-col p-0!"
158+
className="flex flex-col"
154159
data-testid={`settings-field-${props.id}`}
155160
>
156-
<div className={cn( 'w-full flex flex-row items-center justify-between gap-3 border-b border-border group-hover:border-primary p-3', position === "right" && "flex-row-reverse", currentValue === props.value && 'border-primary!')}>
161+
<div className={cn( 'w-full flex flex-row items-center justify-between gap-3 border-b border-border group-hover:border-primary px-5 py-4', position === "right" && "flex-row-reverse", currentValue === props.value && 'border-primary!')}>
157162
<RadioGroupItem
158163
className={cn("disabled:opacity-100", className)}
159164
disabled={disabled}
160165
{...props}
161166
/>
162-
<FieldTitle className="font-bold">
167+
<FieldTitle className="font-bold text-base text-foreground">
163168
{typeof label === 'string' ? <RawHTML>{label}</RawHTML> : label}
164169
</FieldTitle>
165170
</div>
166-
<FieldContent className={cn('p-3 flex items-center justify-center')} >
167-
<div className="flex flex-col items-start gap-3 w-full">
171+
<FieldContent className={cn('px-5 py-4 w-full')} >
172+
<div className="flex flex-col gap-3 w-full">
168173
{description && (
169174
<FieldDescription className="text-center">
170175
{description}
171176
</FieldDescription>
172177
)}
173178
{image && (
174179
typeof image === 'string' ? (
175-
<img src={image} alt={typeof label === 'string' ? label : 'Option image'} className="w-full h-auto object-contain" />
180+
<img src={image} alt={typeof label === 'string' ? label : 'Option image'} className="block w-full h-auto object-contain" />
176181
) : (
177182
image
178183
)

src/components/ui/rich-text-editor.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,22 @@ function RichTextEditor({
5252
const [fontFamily, setFontFamily] = React.useState("Sans Serif");
5353
const [textStyle, setTextStyle] = React.useState("Paragraph");
5454

55-
// Sync internal content with value prop if it changes and is different
55+
// Sync the contentEditable DOM with the value/defaultValue prop.
56+
//
57+
// The contentEditable is intentionally NOT bound via dangerouslySetInnerHTML:
58+
// React would re-apply innerHTML on every keystroke-driven re-render and
59+
// collapse the selection to the start, so each typed character appears to
60+
// prepend (the "reversed / RTL" typing effect). Here we only write innerHTML
61+
// when the incoming content actually differs AND the editor is not focused,
62+
// so external/programmatic updates still land but active typing keeps its caret.
5663
React.useEffect(() => {
57-
if (value !== undefined && editorRef.current && value !== editorRef.current.innerHTML) {
58-
editorRef.current.innerHTML = value;
59-
}
60-
}, [value]);
64+
const el = editorRef.current;
65+
if (!el) return;
66+
const next = value ?? defaultValue ?? "";
67+
if (next === el.innerHTML) return;
68+
if (document.activeElement === el) return;
69+
el.innerHTML = next;
70+
}, [value, defaultValue]);
6171

6272
const executeCommand = (command: string, value?: string) => {
6373
document.execCommand(command, false, value);
@@ -315,7 +325,6 @@ function RichTextEditor({
315325
ref={editorRef}
316326
contentEditable
317327
className="w-full h-full px-3 py-2 text-sm outline-none bg-transparent prose prose-sm max-w-none"
318-
dangerouslySetInnerHTML={{ __html: value ?? defaultValue }}
319328
suppressContentEditableWarning
320329
data-placeholder={placeholder}
321330
onInput={(e) => {

0 commit comments

Comments
 (0)