Skip to content

Commit 3a99389

Browse files
committed
fix: avoid readonly form prop writes in preview controls
1 parent a5b734e commit 3a99389

7 files changed

Lines changed: 98 additions & 32 deletions

File tree

packages/checkbox/src/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ function CheckboxBubbleInput(props: ScopedProps<CheckboxBubbleInputProps>): Fict
371371
'aria-hidden': true,
372372
defaultChecked: prop(context.defaultChecked),
373373
disabled: prop(context.disabled),
374-
form: prop(context.form),
374+
'attr:form': prop(context.form),
375375
name: prop(context.name),
376376
required: prop(context.required),
377377
value: prop(context.value),

packages/radio-group/src/radio-group.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ function RadioGroupItem(props: ScopedProps<RadioGroupItemProps>): FictNode {
373373
context.setCurrentTabStop(value)
374374
},
375375
),
376+
value,
376377
},
377378
) as unknown as ScopedProps<RadioProps>
378379

packages/radio-group/src/radio.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,11 @@ function Radio(props: ScopedProps<RadioProps>): FictNode {
8383
const {
8484
__scopeRadio,
8585
checked: checkedProp = false,
86+
form: formInput,
87+
name: nameInput,
8688
required,
8789
onCheck,
90+
value: valueInput,
8891
...radioProps
8992
} = props
9093
const button = createSignal<HTMLButtonElement | null>(null)
@@ -97,13 +100,13 @@ function Radio(props: ScopedProps<RadioProps>): FictNode {
97100
const requiredValue = () =>
98101
required === undefined ? undefined : Boolean(readValue(required as MaybeAccessor<boolean | undefined>))
99102
const form = () =>
100-
props.form === undefined ? undefined : readValue(props.form as MaybeAccessor<string | undefined>)
103+
formInput === undefined ? undefined : readValue(formInput as MaybeAccessor<string | undefined>)
101104
const name = () =>
102-
props.name === undefined ? undefined : readValue(props.name as MaybeAccessor<string | undefined>)
105+
nameInput === undefined ? undefined : readValue(nameInput as MaybeAccessor<string | undefined>)
103106
const value = () =>
104-
props.value === undefined
107+
valueInput === undefined
105108
? 'on'
106-
: (readValue(props.value as MaybeAccessor<JSX.IntrinsicElements['input']['value'] | undefined>) ??
109+
: (readValue(valueInput as MaybeAccessor<JSX.IntrinsicElements['input']['value'] | undefined>) ??
107110
'on')
108111
const hasConsumerStoppedPropagationRef = { current: false }
109112
const isFormControl = () => {
@@ -123,8 +126,6 @@ function Radio(props: ScopedProps<RadioProps>): FictNode {
123126
__scopeRadio: undefined,
124127
checked: undefined,
125128
disabled: prop(() => (disabled() ? true : undefined)),
126-
form: prop(form),
127-
name: prop(name),
128129
onCheck: undefined,
129130
ref: undefined,
130131
required: undefined,
@@ -153,11 +154,11 @@ function Radio(props: ScopedProps<RadioProps>): FictNode {
153154
checked={checked}
154155
control={button}
155156
disabled={props.disabled}
156-
form={props.form}
157-
name={props.name}
157+
form={formInput}
158+
name={nameInput}
158159
required={required}
159160
style={{ transform: 'translateX(-100%)' }}
160-
value={props.value}
161+
value={valueInput}
161162
/>
162163
) : null) as unknown as FictNode
163164

@@ -206,6 +207,7 @@ function RadioIndicator(props: ScopedProps<RadioIndicatorProps>): FictNode {
206207
RadioIndicator.displayName = INDICATOR_NAME
207208

208209
function RadioBubbleInput(props: RadioBubbleInputProps): FictNode {
210+
const { form: formProp, ...inputRestProps } = props
209211
const ref = createSignal<HTMLInputElement | null>(null)
210212
const composedRefs = useComposedRefs(
211213
props.ref as PossibleRef<HTMLInputElement>,
@@ -236,7 +238,7 @@ function RadioBubbleInput(props: RadioBubbleInputProps): FictNode {
236238
})
237239

238240
const inputProps = mergeProps(
239-
() => props as Record<string, unknown>,
241+
() => inputRestProps as Record<string, unknown>,
240242
{
241243
'aria-hidden': true,
242244
bubbles: undefined,
@@ -245,8 +247,8 @@ function RadioBubbleInput(props: RadioBubbleInputProps): FictNode {
245247
disabled: prop(() =>
246248
props.disabled === undefined ? undefined : Boolean(readValue(props.disabled as MaybeAccessor<unknown>)),
247249
),
248-
form: prop(() =>
249-
props.form === undefined ? undefined : readValue(props.form as MaybeAccessor<string | undefined>),
250+
'attr:form': prop(() =>
251+
formProp === undefined ? undefined : readValue(formProp as MaybeAccessor<string | undefined>),
250252
),
251253
name: prop(() =>
252254
props.name === undefined ? undefined : readValue(props.name as MaybeAccessor<string | undefined>),

packages/radio-group/test/index.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,33 @@ describe('@fictjs/radio-group', () => {
143143
expect(item.disabled).toBe(true)
144144
expect(item.getAttribute('data-disabled')).toBe('')
145145
})
146+
147+
it('keeps form association props on the hidden radio input instead of the button', async () => {
148+
const container = document.createElement('div')
149+
document.body.append(container)
150+
151+
mount(() => (
152+
<>
153+
<form id="billing-form" />
154+
<RadioGroup name="plan" required defaultValue="pro">
155+
<RadioGroupItem data-testid="item" form="billing-form" value="pro">
156+
Pro
157+
</RadioGroupItem>
158+
</RadioGroup>
159+
</>
160+
), container)
161+
162+
await waitForUpdates()
163+
164+
const button = container.querySelector('[data-testid="item"]') as HTMLButtonElement
165+
const input = container.querySelector('input[type="radio"]') as HTMLInputElement
166+
167+
expect(button.hasAttribute('form')).toBe(false)
168+
expect(button.hasAttribute('name')).toBe(false)
169+
expect(button.hasAttribute('required')).toBe(false)
170+
expect(input.getAttribute('form')).toBe('billing-form')
171+
expect(input.getAttribute('name')).toBe('plan')
172+
expect(input.hasAttribute('required')).toBe(true)
173+
expect(input.value).toBe('pro')
174+
})
146175
})

packages/slider/src/slider.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,7 @@ function SliderThumb(props: ScopedProps<SliderThumbProps>): FictNode {
779779
SliderThumb.displayName = THUMB_NAME
780780

781781
function SliderBubbleInput(props: ScopedProps<SliderBubbleInputProps>): FictNode {
782+
const { form: formProp, ...inputRestProps } = props
782783
const ref = createSignal<HTMLInputElement | null>(null)
783784
const previousValue = usePrevious(props.value)
784785

@@ -803,15 +804,15 @@ function SliderBubbleInput(props: ScopedProps<SliderBubbleInputProps>): FictNode
803804
})
804805

805806
const inputProps = mergeProps(
806-
() => props as Record<string, unknown>,
807+
() => inputRestProps as Record<string, unknown>,
807808
{
808809
__scopeSlider: undefined,
809810
defaultValue: prop(() => {
810811
const nextValue = readValue(props.value)
811812
return nextValue === undefined ? '' : String(nextValue)
812813
}),
813-
form: prop(() =>
814-
props.form === undefined ? undefined : readValue(props.form as MaybeAccessor<string | undefined>),
814+
'attr:form': prop(() =>
815+
formProp === undefined ? undefined : readValue(formProp as MaybeAccessor<string | undefined>),
815816
),
816817
name: prop(() =>
817818
props.name === undefined ? undefined : readValue(props.name as MaybeAccessor<string | undefined>),

packages/switch/src/index.tsx

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,24 +80,34 @@ function getState(checked: boolean): 'checked' | 'unchecked' {
8080
}
8181

8282
function Switch(props: ScopedProps<SwitchProps>): FictNode {
83+
const {
84+
__scopeSwitch,
85+
checked: checkedInput,
86+
defaultChecked: defaultCheckedInput,
87+
form: formInput,
88+
name: nameInput,
89+
onCheckedChange,
90+
required: requiredInput,
91+
...buttonProps
92+
} = props
8393
const button = createSignal<HTMLButtonElement | null>(null)
8494
const bubbleInput = createSignal<HTMLInputElement | null>(null)
8595
const isFormControl = createSignal(true)
8696
const checkedProp = () =>
87-
props.checked === undefined ? undefined : readValue(props.checked as MaybeAccessor<boolean | undefined>)
97+
checkedInput === undefined ? undefined : readValue(checkedInput as MaybeAccessor<boolean | undefined>)
8898
const defaultChecked = () =>
89-
props.defaultChecked === undefined ? false : (readValue(props.defaultChecked) ?? false)
99+
defaultCheckedInput === undefined ? false : (readValue(defaultCheckedInput) ?? false)
90100
const required = () =>
91-
props.required === undefined ? undefined : readValue(props.required as MaybeAccessor<boolean | undefined>)
101+
requiredInput === undefined ? undefined : readValue(requiredInput as MaybeAccessor<boolean | undefined>)
92102
const disabled = () => Boolean(readValue(props.disabled as MaybeAccessor<unknown>))
93103
const name = () =>
94-
props.name === undefined ? undefined : readValue(props.name as MaybeAccessor<string | undefined>)
104+
nameInput === undefined ? undefined : readValue(nameInput as MaybeAccessor<string | undefined>)
95105
const value = () =>
96106
props.value === undefined
97107
? 'on'
98108
: (readValue(props.value as MaybeAccessor<SwitchValue | undefined>) ?? 'on')
99109
const form = () =>
100-
props.form === undefined ? undefined : readValue(props.form as MaybeAccessor<string | undefined>)
110+
formInput === undefined ? undefined : readValue(formInput as MaybeAccessor<string | undefined>)
101111
const composedRefs = useComposedRefs(
102112
props.ref as PossibleRef<HTMLButtonElement>,
103113
(node) => button(node),
@@ -106,7 +116,7 @@ function Switch(props: ScopedProps<SwitchProps>): FictNode {
106116
prop: checkedProp,
107117
defaultProp: defaultChecked,
108118
caller: SWITCH_NAME,
109-
...(props.onCheckedChange ? { onChange: props.onCheckedChange } : {}),
119+
...(onCheckedChange ? { onChange: onCheckedChange } : {}),
110120
}
111121
const [checked, setChecked] = useControllableState<boolean>(controllableStateProps)
112122
let hasConsumerStoppedPropagation = false
@@ -175,17 +185,12 @@ function Switch(props: ScopedProps<SwitchProps>): FictNode {
175185
'data-state': prop(() => getState(checked())),
176186
'data-disabled': prop(() => (disabled() ? '' : undefined)),
177187
},
178-
() => props as Record<string, unknown>,
188+
() => buttonProps as Record<string, unknown>,
179189
{
180190
__scopeSwitch: undefined,
181-
checked: undefined,
182-
defaultChecked: undefined,
183-
form: undefined,
184-
name: undefined,
185191
onCheckedChange: undefined,
186192
onClick: handleClick,
187193
ref: undefined,
188-
required: undefined,
189194
value: prop(value),
190195
},
191196
)
@@ -206,7 +211,7 @@ function Switch(props: ScopedProps<SwitchProps>): FictNode {
206211
) : null) as unknown as FictNode
207212

208213
return (
209-
<SwitchProvider scope={props.__scopeSwitch} checked={checked} disabled={disabled}>
214+
<SwitchProvider scope={__scopeSwitch} checked={checked} disabled={disabled}>
210215
<>
211216
<Primitive.button {...switchProps} ref={composedRefs} />
212217
{bubbleInputNode}
@@ -234,19 +239,20 @@ function SwitchThumb(props: ScopedProps<SwitchThumbProps>): FictNode {
234239
SwitchThumb.displayName = THUMB_NAME
235240

236241
function SwitchBubbleInput(props: SwitchBubbleInputProps): FictNode {
242+
const { form: formProp, ...inputRestProps } = props
237243
const controlSize = useSize(props.control)
238244

239245
const inputProps = mergeProps(
240-
() => props as Record<string, unknown>,
246+
() => inputRestProps as Record<string, unknown>,
241247
{
242248
'aria-hidden': true,
243249
checked: prop(props.checked),
244250
control: undefined,
245251
bubbles: undefined,
246252
children: undefined,
247253
disabled: prop(() => (props.disabled === undefined ? undefined : Boolean(readValue(props.disabled)))),
248-
form: prop(() =>
249-
props.form === undefined ? undefined : readValue(props.form as MaybeAccessor<string | undefined>),
254+
'attr:form': prop(() =>
255+
formProp === undefined ? undefined : readValue(formProp as MaybeAccessor<string | undefined>),
250256
),
251257
name: prop(() =>
252258
props.name === undefined ? undefined : readValue(props.name as MaybeAccessor<string | undefined>),

packages/switch/test/index.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,33 @@ describe('@fictjs/switch', () => {
131131
expect(input.checked).toBe(true)
132132
})
133133

134+
it('keeps form association props on the hidden input instead of the button', async () => {
135+
const container = document.createElement('div')
136+
document.body.append(container)
137+
138+
render(() => (
139+
<>
140+
<form id="settings-form" />
141+
<Root form="settings-form" name="airplane-mode" required value="enabled">
142+
<Thumb />
143+
</Root>
144+
</>
145+
), container)
146+
147+
await flushEffects()
148+
149+
const button = container.querySelector('button') as HTMLButtonElement
150+
const input = container.querySelector('input[type="checkbox"]') as HTMLInputElement
151+
152+
expect(button.hasAttribute('form')).toBe(false)
153+
expect(button.hasAttribute('name')).toBe(false)
154+
expect(button.hasAttribute('required')).toBe(false)
155+
expect(input.getAttribute('form')).toBe('settings-form')
156+
expect(input.getAttribute('name')).toBe('airplane-mode')
157+
expect(input.getAttribute('value')).toBe('enabled')
158+
expect(input.hasAttribute('required')).toBe(true)
159+
})
160+
134161
it('forwards ref mount and cleanup through the switch root', async () => {
135162
const calls: Array<string | null> = []
136163
const container = document.createElement('div')

0 commit comments

Comments
 (0)