@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.
- package/LICENSE +21 -0
- package/README.md +327 -0
- package/package.json +52 -0
- package/src/Accordion.vue +84 -0
- package/src/AsyncAction.vue +66 -0
- package/src/AsyncSearch.vue +262 -0
- package/src/AsyncSearchResults.vue +74 -0
- package/src/BottomNav.vue +125 -0
- package/src/Checkbox.vue +112 -0
- package/src/CircularProgress.vue +57 -0
- package/src/Copy.vue +51 -0
- package/src/CurrencyInput.vue +143 -0
- package/src/Email.vue +17 -0
- package/src/Field.vue +54 -0
- package/src/FieldTip.scss +76 -0
- package/src/FieldTip.ts +57 -0
- package/src/FixAndroidTyping.ts +13 -0
- package/src/Form.ts +124 -0
- package/src/Form.vue +78 -0
- package/src/Gallery.vue +174 -0
- package/src/Loading.vue +32 -0
- package/src/MenuButton.vue +95 -0
- package/src/Modal.vue +365 -0
- package/src/ModalOnClick.vue +73 -0
- package/src/OptionSwitch.vue +110 -0
- package/src/Password.vue +85 -0
- package/src/RowSelect.ts +211 -0
- package/src/SearchField.vue +124 -0
- package/src/SegmentedControl.vue +85 -0
- package/src/SelectBox.vue +468 -0
- package/src/Splash.vue +36 -0
- package/src/TabBar.vue +158 -0
- package/src/TextField.vue +116 -0
- package/src/TheWrap.vue +30 -0
- package/src/ThumbnailGrid.vue +166 -0
- package/src/TreeView.vue +308 -0
- package/src/_shared.scss +22 -0
- package/src/copyTextToClipboard.ts +3 -0
- package/src/downsizeImage.ts +78 -0
- package/src/inModal.ts +8 -0
- package/src/main.ts +78 -0
- package/src/messages.ts +39 -0
- package/src/modalStack.ts +181 -0
- package/src/styles.d.ts +6 -0
- package/src/toast/ToastShell.vue +70 -0
- package/src/toast/showToast.ts +81 -0
- package/src/touchSlop.ts +6 -0
- package/src/useSelection.ts +99 -0
- 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
|
+
}
|
package/src/FieldTip.ts
ADDED
|
@@ -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>
|