| @@ -42,10 +42,42 @@ export const spinnerClasses = `animate-spin ${iconClasses}`.trim() |
| 42 | 42 | </script> |
| 43 | 43 | <script client> |
| 44 | 44 | const emit = defineEmits() |
| 45 | | -const disabledNow = |
| 45 | + |
| 46 | +/* |
| 47 | + * `disabled` and `loading` are read as live props, not as a server snapshot. |
| 48 | + * |
| 49 | + * This used to be `const disabledNow = ` — a value computed |
| 50 | + * before the page was sent and never looked at again. So the most common piece |
| 51 | + * of button state in an application could not be expressed: |
| 52 | + * |
| 53 | + * |
| 54 | + <Button :disabled="saving()" @click="save"> |
| 55 | + Save |
| 56 | + </Button> |
| 57 | + * |
| 58 | + * `saving()` is a signal. On the server it is not yet meaningful, so the button |
| 59 | + * shipped enabled and STAYED enabled for the life of the page — through the |
| 60 | + * exact async action it was there to guard. Double submits, destructive |
| 61 | + * deletes and rotate-key confirmations all land on this. |
| 62 | + * |
| 63 | + * useReactiveProp wires the parent's binding into a local signal: the parent's |
| 64 | + * bindAttr writes the attribute on our root, a MutationObserver picks it up, |
| 65 | + * and the signal flips. Dialog already does this for `open` (#1704) and Switch |
| 66 | + * for `checked`, so this is the package's existing answer rather than a new one. |
| 67 | + * |
| 68 | + * See stacksjs/stx#1997. |
| 69 | + */ |
| 70 | +const isLoading = useReactiveProp('loading', ) |
| 71 | +const disabledProp = useReactiveProp('disabled', ) |
| 72 | + |
| 73 | +// Loading implies disabled, the same combination the server computes for the |
| 74 | +// initial render, so the two agree before and after hydration. |
| 75 | +const isDisabled = derived(() => disabledProp() || isLoading()) |
| 46 | 76 | |
| 47 | 77 | function onClick(event) { |
| 48 | | - if (disabledNow) { |
| 78 | + // Read through the signal: a button disabled after render must stop guarding |
| 79 | + // on the live value, not on what was true when the page was built. |
| 80 | + if (isDisabled()) { |
| 49 | 81 | event.preventDefault() |
| 50 | 82 | return |
| 51 | 83 | } |
| @@ -56,26 +88,37 @@ function onClick(event) { |
| 56 | 88 | type="" |
| 57 | 89 | class="" |
| 58 | 90 | |
| 59 | | - @click="onClick($event)" |
| 91 | + :disabled="isDisabled()" |
| 60 | 92 | aria-busy="false" |
| 93 | + :aria-busy="isLoading() ? 'true' : 'false'" |
| 94 | + @click="onClick($event)" |
| 61 | 95 | > |
| 62 | | - |
| 70 | | - |
| 76 | 119 | <slot /> |
| 77 | | -
|