@greendrake/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -0
  3. package/package.json +52 -0
  4. package/src/Accordion.vue +84 -0
  5. package/src/AsyncAction.vue +66 -0
  6. package/src/AsyncSearch.vue +262 -0
  7. package/src/AsyncSearchResults.vue +74 -0
  8. package/src/BottomNav.vue +125 -0
  9. package/src/Checkbox.vue +112 -0
  10. package/src/CircularProgress.vue +57 -0
  11. package/src/Copy.vue +51 -0
  12. package/src/CurrencyInput.vue +143 -0
  13. package/src/Email.vue +17 -0
  14. package/src/Field.vue +54 -0
  15. package/src/FieldTip.scss +76 -0
  16. package/src/FieldTip.ts +57 -0
  17. package/src/FixAndroidTyping.ts +13 -0
  18. package/src/Form.ts +124 -0
  19. package/src/Form.vue +78 -0
  20. package/src/Gallery.vue +174 -0
  21. package/src/Loading.vue +32 -0
  22. package/src/MenuButton.vue +95 -0
  23. package/src/Modal.vue +365 -0
  24. package/src/ModalOnClick.vue +73 -0
  25. package/src/OptionSwitch.vue +110 -0
  26. package/src/Password.vue +85 -0
  27. package/src/RowSelect.ts +211 -0
  28. package/src/SearchField.vue +124 -0
  29. package/src/SegmentedControl.vue +85 -0
  30. package/src/SelectBox.vue +468 -0
  31. package/src/Splash.vue +36 -0
  32. package/src/TabBar.vue +158 -0
  33. package/src/TextField.vue +116 -0
  34. package/src/TheWrap.vue +30 -0
  35. package/src/ThumbnailGrid.vue +166 -0
  36. package/src/TreeView.vue +308 -0
  37. package/src/_shared.scss +22 -0
  38. package/src/copyTextToClipboard.ts +3 -0
  39. package/src/downsizeImage.ts +78 -0
  40. package/src/inModal.ts +8 -0
  41. package/src/main.ts +78 -0
  42. package/src/messages.ts +39 -0
  43. package/src/modalStack.ts +181 -0
  44. package/src/styles.d.ts +6 -0
  45. package/src/toast/ToastShell.vue +70 -0
  46. package/src/toast/showToast.ts +81 -0
  47. package/src/touchSlop.ts +6 -0
  48. package/src/useSelection.ts +99 -0
  49. package/src/validationScope.ts +6 -0
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ const isValid = (v: string): boolean => !v || /^[\d,.]+$/.test(v)
3
+ const limitPrecision = (n: string, precision: number): string => {
4
+ if (!n.includes('.')) {
5
+ return n
6
+ }
7
+ const parts = n.split('.')
8
+ if (!precision) {
9
+ return parts[0]
10
+ }
11
+ return parts[0] + '.' + parts[1].substring(0, precision)
12
+ }
13
+ const doInsertThousandsSeparators = (n: string): string => n.replace(/\D/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',')
14
+ const insertThousandsSeparators = (n: string): string => {
15
+ if (!n.includes('.')) {
16
+ return doInsertThousandsSeparators(n)
17
+ }
18
+ const parts = n.split('.')
19
+ return doInsertThousandsSeparators(parts[0]) + '.' + parts[1]
20
+ }
21
+ const ensureDecimals = (n: string, precision: number): string => {
22
+ const parts = n.split('.')
23
+ if (!precision) {
24
+ return parts[0]
25
+ }
26
+ return parts[0] + '.' + (parts[1] || '').padEnd(precision, '0')
27
+ }
28
+ </script>
29
+ <script setup lang="ts">
30
+ import { ref, watch, onMounted } from 'vue'
31
+ import { hasFinePointer } from '@greendrake/util'
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ // Accessible name for the amount input (the visible caption usually
36
+ // names the composite row, not this control).
37
+ ariaLabel?: string
38
+ placeholder?: string
39
+ autofocus?: boolean
40
+ symbol?: string
41
+ hideSymbol?: boolean
42
+ doNotShowZeroDecimals?: boolean
43
+ forceDecimals?: boolean
44
+ disabled?: boolean
45
+ min?: number | string | null
46
+ precision?: number
47
+ }>(),
48
+ {
49
+ precision: 2
50
+ }
51
+ )
52
+ const model = defineModel<string | null>()
53
+ const groomValue = (v: string | null, forceDecimals?: boolean): string => {
54
+ if (v === null || v === '') {
55
+ return ''
56
+ }
57
+ let result = insertThousandsSeparators(limitPrecision(v, props.precision))
58
+ if (props.forceDecimals || forceDecimals) {
59
+ result = ensureDecimals(result, props.precision)
60
+ }
61
+ if (props.doNotShowZeroDecimals && result.endsWith(`.${'0'.repeat(props.precision)}`)) {
62
+ result = result.substring(0, result.length - (1 + props.precision))
63
+ }
64
+ return result
65
+ }
66
+ const input = ref<HTMLInputElement | null>(null)
67
+ let previous: string | null = null
68
+ const isFullyValid = (v: string): boolean => {
69
+ if (!isValid(v)) {
70
+ return false
71
+ }
72
+ if (props.min && Number(props.min) > Number(v)) {
73
+ return false
74
+ }
75
+ return true
76
+ }
77
+ const onInput = () => {
78
+ const inp = input.value
79
+ if (!inp) return
80
+ if (isFullyValid(inp.value)) {
81
+ previous = inp.value
82
+ if (inp.value) {
83
+ const groomed = groomValue(inp.value)
84
+ if (inp.value !== groomed) {
85
+ const whole = inp.value.split('.')[0]
86
+ const caretPos = insertThousandsSeparators(whole).length - whole.length + (inp.selectionStart ?? 0)
87
+ inp.value = groomed
88
+ inp.setSelectionRange(caretPos, caretPos)
89
+ }
90
+ }
91
+ model.value = inp.value ? ensureDecimals(inp.value.replace(/,/g, ''), props.precision) : null
92
+ } else if (previous !== null && input.value) {
93
+ input.value.value = previous
94
+ }
95
+ }
96
+ const model2input = () => {
97
+ if (input.value) {
98
+ input.value.value = groomValue(model.value ?? null)
99
+ }
100
+ }
101
+ watch(model, mv => {
102
+ if (input.value && groomValue(input.value.value, true) !== groomValue(mv ?? null, true)) {
103
+ model2input()
104
+ }
105
+ })
106
+ onMounted(() => {
107
+ if (model.value) {
108
+ model2input()
109
+ }
110
+ // Pointer devices only — see TextField for why touch is excluded.
111
+ if (props.autofocus && input.value && hasFinePointer()) {
112
+ input.value.focus()
113
+ }
114
+ })
115
+ </script>
116
+ <template>
117
+ <div :data-currency="symbol" class="CurrencyInput" :class="{ 'CurrencyInput--no-symbol': hideSymbol }">
118
+ <input ref="input" type="text" :aria-label="ariaLabel" :placeholder="placeholder" :disabled="disabled" @input="onInput" />
119
+ </div>
120
+ </template>
121
+ <style lang="scss">
122
+ .CurrencyInput {
123
+ position: relative;
124
+ input {
125
+ text-align: end;
126
+ width: 100%;
127
+ }
128
+ &:not(.CurrencyInput--no-symbol) {
129
+ &::before {
130
+ opacity: 0.8;
131
+ font-weight: bold;
132
+ content: attr(data-currency);
133
+ position: absolute;
134
+ top: 50%;
135
+ transform: translateY(-50%);
136
+ inset-inline-start: 8px;
137
+ }
138
+ input {
139
+ padding-inline-start: 30px;
140
+ }
141
+ }
142
+ }
143
+ </style>
package/src/Email.vue ADDED
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ import { isEmailValid } from '@greendrake/domains'
3
+ import TextField from './TextField.vue'
4
+ import { messages } from './messages'
5
+
6
+ const model = defineModel<string>({ default: '' })
7
+ defineProps<{
8
+ placeholder?: string
9
+ autofocus?: boolean
10
+ id?: string
11
+ validateOnBlur?: boolean
12
+ }>()
13
+ const validator = (v: string): true | string => isEmailValid(v) || messages.invalidEmail
14
+ </script>
15
+ <template>
16
+ <TextField :id="id" v-model="model" type="email" :validator="validator" :placeholder="placeholder" :autofocus="autofocus" :validate-on-blur="validateOnBlur" v-bind="$attrs" />
17
+ </template>
package/src/Field.vue ADDED
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ label?: string
4
+ // Set when the slot holds a GROUP of controls (radio set, composite row):
5
+ // the caption then names a role="group" wrapper instead of wrapping the
6
+ // content in a <label>, which would smear one label across every control.
7
+ group?: boolean
8
+ // Set when the slot's control cannot be labelled implicitly — a
9
+ // contenteditable is not a labelable element. A <label> around one binds to
10
+ // the first labelable descendant instead, and a rich editor's is its
11
+ // toolbar's first <button>: the caption would then name that button, and
12
+ // every click in the field would be FORWARDED to it as a real click, so
13
+ // clicking into the text would silently press Bold.
14
+ //
15
+ // Such a control names itself (RichEditor's ariaLabel puts it on the
16
+ // contenteditable), so the caption is left as plain text — no role="group",
17
+ // which would only announce the name a second time.
18
+ unlabelable?: boolean
19
+ }>()
20
+ </script>
21
+ <template>
22
+ <div class="Field">
23
+ <template v-if="label && group">
24
+ <span class="Field__caption">{{ label }}</span>
25
+ <div role="group" :aria-label="label" class="Field__group">
26
+ <slot />
27
+ </div>
28
+ </template>
29
+ <!-- No wrapper at all: .Field is already the column the <label> below
30
+ would have been, so the caption and control sit exactly where the
31
+ labelled variant puts them. -->
32
+ <template v-else-if="label && unlabelable">
33
+ <span class="Field__caption">{{ label }}</span>
34
+ <slot />
35
+ </template>
36
+ <!-- The single control renders INSIDE the label (implicit association):
37
+ the caption is the control's accessible name, zero id plumbing. -->
38
+ <label v-else-if="label">
39
+ <span class="Field__caption">{{ label }}</span>
40
+ <slot />
41
+ </label>
42
+ <slot v-else />
43
+ </div>
44
+ </template>
45
+ <style lang="scss" scoped>
46
+ .Field {
47
+ display: flex;
48
+ flex-direction: column;
49
+ > label {
50
+ display: flex;
51
+ flex-direction: column;
52
+ }
53
+ }
54
+ </style>
@@ -0,0 +1,76 @@
1
+ // Styling for the field-validation tooltips FieldTip.ts creates, scoped to the
2
+ // `fieldTip` theme it applies. Self-contained (there is no global tippy.js base
3
+ // stylesheet) so it styles only these tips and never the other themed,
4
+ // arrow-less tippies (menus, async-search): a filled box with a pointer arrow,
5
+ // its box/arrow geometry mirroring tippy.js' own tippy.css. The fill is the
6
+ // --tippy-background-color token, defaulting to violet.
7
+ .tippy-box[data-theme~='fieldTip'] {
8
+ position: relative;
9
+ background-color: var(--tippy-background-color, #5335e8);
10
+ color: #fff;
11
+ border-radius: var(--border-radius, 4px);
12
+ font-size: var(--font-md);
13
+ line-height: 1.4;
14
+ white-space: normal;
15
+ outline: 0;
16
+
17
+ .tippy-content {
18
+ position: relative;
19
+ padding: 5px 9px;
20
+ z-index: 1;
21
+ }
22
+
23
+ // Arrow: tippy positions the element (inline transform); these rules give it
24
+ // its triangle shape per placement. `currentColor` picks up the arrow's color
25
+ // (set to the box fill above) — the same mechanism tippy.css uses.
26
+ > .tippy-arrow {
27
+ position: absolute;
28
+ width: 16px;
29
+ height: 16px;
30
+ color: var(--tippy-background-color, #5335e8);
31
+ }
32
+ > .tippy-arrow::before {
33
+ content: '';
34
+ position: absolute;
35
+ border-color: transparent;
36
+ border-style: solid;
37
+ }
38
+ &[data-placement^='top'] > .tippy-arrow {
39
+ bottom: 0;
40
+ }
41
+ &[data-placement^='top'] > .tippy-arrow::before {
42
+ bottom: -7px;
43
+ left: 0;
44
+ border-width: 8px 8px 0;
45
+ border-top-color: currentColor;
46
+ transform-origin: center top;
47
+ }
48
+ &[data-placement^='bottom'] > .tippy-arrow {
49
+ top: 0;
50
+ }
51
+ &[data-placement^='bottom'] > .tippy-arrow::before {
52
+ top: -7px;
53
+ left: 0;
54
+ border-width: 0 8px 8px;
55
+ border-bottom-color: currentColor;
56
+ transform-origin: center bottom;
57
+ }
58
+ &[data-placement^='left'] > .tippy-arrow {
59
+ right: 0;
60
+ }
61
+ &[data-placement^='left'] > .tippy-arrow::before {
62
+ right: -7px;
63
+ border-width: 8px 0 8px 8px;
64
+ border-left-color: currentColor;
65
+ transform-origin: center left;
66
+ }
67
+ &[data-placement^='right'] > .tippy-arrow {
68
+ left: 0;
69
+ }
70
+ &[data-placement^='right'] > .tippy-arrow::before {
71
+ left: -7px;
72
+ border-width: 8px 8px 8px 0;
73
+ border-right-color: currentColor;
74
+ transform-origin: center right;
75
+ }
76
+ }
@@ -0,0 +1,57 @@
1
+ // The declaration has to reach every program that compiles this file — this
2
+ // package's own check, a sibling package, an app, an outside consumer — and a
3
+ // path reference is what travels with the source. The rule's advice does not
4
+ // apply: an `import` cannot carry an ambient wildcard module declaration.
5
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference
6
+ /// <reference path="./styles.d.ts" />
7
+ import { watch, onUnmounted, type Ref, type ComponentPublicInstance } from 'vue'
8
+ import { useTippy } from 'vue-tippy'
9
+ // Self-contained box/arrow styling for these tips, scoped to the `fieldTip` theme
10
+ // applied below (the app ships no global tippy.js base stylesheet).
11
+ import './FieldTip.scss'
12
+
13
+ type Tooltip = ReturnType<typeof useTippy>
14
+
15
+ const getElement = (ref: string | Ref<HTMLElement | ComponentPublicInstance | null>): HTMLElement | null => {
16
+ if (typeof ref === 'string') {
17
+ return document.getElementById(ref)
18
+ }
19
+ const val = ref.value
20
+ if (val === null) {
21
+ return null
22
+ }
23
+ return '$el' in val ? (val.$el as HTMLElement) : val
24
+ }
25
+
26
+ // Binds a manually-triggered tippy tooltip to a field element, driven by a
27
+ // reactive content source: setting it to a string shows the tip (as text —
28
+ // tip messages are plain strings by contract), setting it to false hides it.
29
+ // The tip also resets the source to false when dismissed by the user, so the
30
+ // field can re-trigger it later.
31
+ export default (toWatchRef: Ref<string | false>, targetRef: string | Ref<HTMLElement | ComponentPublicInstance | null>): void => {
32
+ let tooltip: Tooltip | undefined
33
+
34
+ onUnmounted(() => {
35
+ if (tooltip && !tooltip.state.value.isDestroyed) {
36
+ tooltip.destroy()
37
+ }
38
+ })
39
+
40
+ watch(toWatchRef, content => {
41
+ const el = getElement(targetRef)
42
+ if (!content && tooltip) {
43
+ tooltip.destroy()
44
+ } else if (content && el) {
45
+ tooltip = useTippy(el, {
46
+ content,
47
+ theme: 'fieldTip',
48
+ trigger: 'manual',
49
+ interactive: true,
50
+ showOnCreate: true,
51
+ onHidden: () => {
52
+ toWatchRef.value = false
53
+ }
54
+ })
55
+ }
56
+ })
57
+ }
@@ -0,0 +1,13 @@
1
+ import type { Ref } from 'vue'
2
+
3
+ type InputElement = HTMLInputElement | HTMLTextAreaElement
4
+
5
+ // Some Android keyboards compose text without firing the events Vue's v-model
6
+ // relies on, leaving the model behind the actual input value. Called from an
7
+ // @input handler, this syncs the model from the DOM when they diverge.
8
+ export default (inputRef: Ref<InputElement | null>, modelRef: Ref<string>, isValid?: (value: string) => boolean): void => {
9
+ const input = inputRef.value
10
+ if (input && input.value !== modelRef.value && (!isValid || isValid(input.value))) {
11
+ modelRef.value = input.value
12
+ }
13
+ }
package/src/Form.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { computed, ref, shallowRef, type Ref, type ComputedRef } from 'vue'
2
+ import { Retrier } from '@greendrake/util'
3
+ import { messages } from './messages'
4
+
5
+ // Rest states derive from validity/dirtiness; dictated states are imposed by
6
+ // an in-flight or just-finished submission.
7
+ type RestState = 'invalid' | 'dirty' | 'saved'
8
+ type DictatedState = 'confirm' | 'inprogress' | 'success' | 'error'
9
+ export type FormState = RestState | DictatedState
10
+
11
+ export interface FormOptions {
12
+ valid?: Ref<boolean>
13
+ dirty?: Ref<boolean>
14
+ alwaysValid?: boolean
15
+ alwaysDirty?: boolean
16
+ // First action() enters the 'confirm' state; the next one (while it lasts)
17
+ // actually submits.
18
+ requireConfirmation?: boolean
19
+ handler?: () => Promise<unknown>
20
+ // Retry the handler indefinitely (Retrier) until it succeeds or the form
21
+ // is destroyed; the countdown between attempts is surfaced via statusInfo.
22
+ tryPersistently?: boolean
23
+ // How long the respective dictated state lasts before returning to a rest
24
+ // state: a number of ms, false to keep it forever (or, for success, to
25
+ // skip the state entirely), undefined for the 2000ms default.
26
+ successTimeout?: number | false
27
+ confirmTimeout?: number | false
28
+ errorTimeout?: number | false
29
+ }
30
+
31
+ const DEFAULT_STATE_TIMEOUT = 2000
32
+
33
+ // Form submission state machine: validity/dirtiness flow in as refs, action()
34
+ // runs the handler through the confirm/inprogress/success/error lifecycle.
35
+ export default class Form {
36
+ readonly isValid: ComputedRef<boolean>
37
+ readonly isDirty: ComputedRef<boolean>
38
+ readonly state: ComputedRef<FormState>
39
+ // Whether action() would do anything: only in the 'dirty' and 'confirm'
40
+ // states is there something to submit.
41
+ readonly isActionable: ComputedRef<boolean>
42
+ // Retry countdown message while a persistent try is between attempts;
43
+ // empty otherwise.
44
+ readonly statusInfo: Ref<string> = ref('')
45
+
46
+ readonly #options: FormOptions
47
+ readonly #dictatedState = shallowRef<DictatedState | false>(false)
48
+ #retrier?: Retrier<unknown>
49
+ #timeout?: ReturnType<typeof setTimeout>
50
+ #destroyed = false
51
+
52
+ constructor(options: FormOptions = {}) {
53
+ this.#options = options
54
+ this.isValid = computed(() => options.alwaysValid || (options.valid ? options.valid.value : true))
55
+ this.isDirty = computed(() => options.alwaysDirty || (options.dirty ? options.dirty.value : true))
56
+ this.state = computed((): FormState => {
57
+ if (this.#dictatedState.value !== false) {
58
+ return this.#dictatedState.value
59
+ }
60
+ return this.isValid.value ? (this.isDirty.value ? 'dirty' : 'saved') : 'invalid'
61
+ })
62
+ this.isActionable = computed(() => this.state.value === 'dirty' || this.state.value === 'confirm')
63
+ }
64
+
65
+ async action(): Promise<void> {
66
+ if (!this.isActionable.value) {
67
+ return
68
+ }
69
+ this.#clearTimeout()
70
+ if (this.#options.requireConfirmation && this.state.value !== 'confirm') {
71
+ this.#dictatedState.value = 'confirm'
72
+ } else {
73
+ this.#dictatedState.value = 'inprogress'
74
+ try {
75
+ if (this.#options.handler) {
76
+ if (this.#options.tryPersistently) {
77
+ this.#retrier = new Retrier({ attempt: this.#options.handler })
78
+ this.#retrier.on('trying', () => (this.statusInfo.value = ''))
79
+ this.#retrier.on('waiting', secondsLeft => (this.statusInfo.value = messages.retryCountdown.replace('{seconds}', String(secondsLeft))))
80
+ try {
81
+ await this.#retrier.run()
82
+ } finally {
83
+ this.#retrier = undefined
84
+ this.statusInfo.value = ''
85
+ }
86
+ } else {
87
+ await this.#options.handler()
88
+ }
89
+ }
90
+ this.#dictatedState.value = this.#options.successTimeout === false ? false : 'success'
91
+ } catch {
92
+ this.#dictatedState.value = 'error'
93
+ }
94
+ }
95
+ if (this.#destroyed) {
96
+ return
97
+ }
98
+ // Schedule the dictated state's expiry back to a rest state. Only
99
+ // confirm/success/error can be current here: 'inprogress' always ends
100
+ // when the handler settles (re-entry is barred by isActionable).
101
+ const dictated = this.#dictatedState.value
102
+ if (dictated !== false) {
103
+ const timeoutValue = this.#options[`${dictated}Timeout`]
104
+ if (timeoutValue !== false) {
105
+ this.#timeout = setTimeout(() => {
106
+ this.#dictatedState.value = false
107
+ }, timeoutValue ?? DEFAULT_STATE_TIMEOUT)
108
+ }
109
+ }
110
+ }
111
+
112
+ #clearTimeout(): void {
113
+ if (this.#timeout) {
114
+ clearTimeout(this.#timeout)
115
+ this.#timeout = undefined
116
+ }
117
+ }
118
+
119
+ destroy(): void {
120
+ this.#destroyed = true
121
+ this.#retrier?.stop()
122
+ this.#clearTimeout()
123
+ }
124
+ }
package/src/Form.vue ADDED
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import type { Ref } from 'vue'
3
+
4
+ export interface FormRefs {
5
+ dirty?: Ref<boolean>
6
+ valid?: Ref<boolean>
7
+ }
8
+ </script>
9
+ <script setup lang="ts">
10
+ import { computed, onUnmounted } from 'vue'
11
+ import Form from './Form'
12
+
13
+ // A <form> whose submit button doubles as the submission status display,
14
+ // walking the Form state machine: dirty → (confirm →) inprogress →
15
+ // success/error → back to rest. The *Text props label the button per state;
16
+ // missing ones fall back to the raw state name (a dev-time default — real
17
+ // consumers pass their own labels).
18
+ const props = defineProps<{
19
+ savedText?: string
20
+ successText?: string
21
+ inprogressText?: string
22
+ dirtyText?: string
23
+ invalidText?: string
24
+ confirmText?: string
25
+ errorText?: string
26
+ action?: () => Promise<unknown> | unknown
27
+ askToConfirm?: boolean
28
+ refs?: FormRefs
29
+ // Render an empty button (status conveyed by CSS classes only).
30
+ noContent?: boolean
31
+ tryPersistently?: boolean
32
+ // true/undefined → default success display duration; a number → that many
33
+ // ms; false → skip the success state entirely.
34
+ successTimeout?: boolean | number
35
+ }>()
36
+
37
+ const form = new Form({
38
+ requireConfirmation: props.askToConfirm,
39
+ handler: async () => {
40
+ await props.action?.()
41
+ },
42
+ dirty: props.refs?.dirty,
43
+ valid: props.refs?.valid,
44
+ successTimeout: props.successTimeout === true ? undefined : props.successTimeout,
45
+ tryPersistently: props.tryPersistently
46
+ })
47
+ const content = computed(() => {
48
+ if (form.statusInfo.value) {
49
+ return form.statusInfo.value
50
+ }
51
+ if (props.noContent) {
52
+ return ''
53
+ }
54
+ return props[`${form.state.value}Text` as const] || form.state.value
55
+ })
56
+ const computeCls = (baseCls: string): string => {
57
+ let str = `${baseCls} ${baseCls}-${form.state.value}`
58
+ if (form.statusInfo.value) {
59
+ str += ` ${baseCls}-error`
60
+ }
61
+ return str
62
+ }
63
+ const fCls = computed(() => computeCls('form'))
64
+ const bCls = computed(() => computeCls('button'))
65
+ onUnmounted(() => form.destroy())
66
+ const onSubmit = async () => await form.action()
67
+ </script>
68
+ <template>
69
+ <form :class="fCls" @submit.prevent="onSubmit">
70
+ <slot />
71
+ <button type="submit" :class="bCls" :disabled="!form.isActionable.value">{{ content }}</button>
72
+ </form>
73
+ </template>
74
+ <style scoped lang="scss">
75
+ button[type='submit'] {
76
+ width: 100%;
77
+ }
78
+ </style>