adminforth 4.3.2 → 4.4.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.
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="rootClasses">
|
|
3
|
+
<template v-for="i in length" :key="i">
|
|
4
|
+
<div :class="cellClasses(i - 1)" aria-hidden="true">
|
|
5
|
+
{{ code[i - 1] }}
|
|
6
|
+
<span
|
|
7
|
+
v-if="i - 1 === activeIndex && !code[i - 1]"
|
|
8
|
+
class="afcl-code-input-caret w-px h-6 bg-lightInputText dark:bg-darkInputText animate-pulse"
|
|
9
|
+
/>
|
|
10
|
+
</div>
|
|
11
|
+
<span
|
|
12
|
+
v-if="isGroupEnd(i)"
|
|
13
|
+
class="afcl-code-input-separator w-3 h-0.5 shrink-0 rounded-full bg-lightInputBorder dark:bg-darkInputBorder"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
/>
|
|
16
|
+
</template>
|
|
17
|
+
<!-- one real field over the drawn cells: autofill and paste fill the whole code, screen readers read one field -->
|
|
18
|
+
<input
|
|
19
|
+
ref="inputRef"
|
|
20
|
+
v-bind="restAttrs"
|
|
21
|
+
:value="code"
|
|
22
|
+
type="text"
|
|
23
|
+
inputmode="numeric"
|
|
24
|
+
autocomplete="one-time-code"
|
|
25
|
+
spellcheck="false"
|
|
26
|
+
autocorrect="off"
|
|
27
|
+
autocapitalize="off"
|
|
28
|
+
:maxlength="length"
|
|
29
|
+
:disabled="disabled"
|
|
30
|
+
:aria-invalid="invalid || undefined"
|
|
31
|
+
class="afcl-code-input-field absolute inset-0 z-10 w-full h-full p-0 border-0 bg-transparent text-base text-transparent caret-transparent outline-none cursor-text selection:bg-transparent disabled:cursor-not-allowed"
|
|
32
|
+
@input="setCode(($event.target as HTMLInputElement).value)"
|
|
33
|
+
@paste="onPaste"
|
|
34
|
+
@focus="onFocus"
|
|
35
|
+
@blur="onBlur"
|
|
36
|
+
@click="moveCaretToEnd"
|
|
37
|
+
@select="moveCaretToEnd"
|
|
38
|
+
@keydown="onKeydown"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<script setup lang="ts">
|
|
44
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';
|
|
45
|
+
import { type ClassNameValue } from 'tailwind-merge';
|
|
46
|
+
import { mergeClasses } from './classMerge';
|
|
47
|
+
|
|
48
|
+
defineOptions({ inheritAttrs: false });
|
|
49
|
+
|
|
50
|
+
const props = withDefaults(defineProps<{
|
|
51
|
+
length?: number,
|
|
52
|
+
// a separator is drawn after every `groupSize` digits, 0 draws none
|
|
53
|
+
groupSize?: number,
|
|
54
|
+
invalid?: boolean,
|
|
55
|
+
disabled?: boolean,
|
|
56
|
+
autofocus?: boolean,
|
|
57
|
+
// the code is the main control of the screen or dialog: after a click anywhere, e.g. on a copy button,
|
|
58
|
+
// the focus comes back to it, Tab still moves on to other controls, a code pasted anywhere on the page fills it
|
|
59
|
+
keepFocus?: boolean,
|
|
60
|
+
}>(), {
|
|
61
|
+
length: 6,
|
|
62
|
+
groupSize: 0,
|
|
63
|
+
invalid: false,
|
|
64
|
+
disabled: false,
|
|
65
|
+
autofocus: false,
|
|
66
|
+
keepFocus: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const emit = defineEmits<{
|
|
70
|
+
(e: 'complete', code: string): void,
|
|
71
|
+
}>();
|
|
72
|
+
|
|
73
|
+
const code = defineModel<string>({ default: '' });
|
|
74
|
+
|
|
75
|
+
const NON_DIGIT_RE = /\D/g;
|
|
76
|
+
// controls Tab moves the focus to, keepFocus leaves it there
|
|
77
|
+
const FOCUSABLE_CONTROL_SELECTOR = 'a[href], button, input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
|
|
78
|
+
// another field takes the typing, keepFocus leaves the focus there after a click too
|
|
79
|
+
const EDITABLE_SELECTOR = 'input, select, textarea, [contenteditable="true"]';
|
|
80
|
+
// the caret stays at the end, so keys which move it inside the code do nothing
|
|
81
|
+
const CARET_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
82
|
+
|
|
83
|
+
const inputRef = ref<HTMLInputElement | null>(null);
|
|
84
|
+
const isFocused = ref(false);
|
|
85
|
+
// how the user moved the focus last time, Tab is left alone and a click brings the focus back
|
|
86
|
+
let lastInteraction: 'pointer' | 'keyboard' = 'pointer';
|
|
87
|
+
|
|
88
|
+
const attrs = useAttrs();
|
|
89
|
+
const restAttrs = computed(() => {
|
|
90
|
+
const { class: _class, ...rest } = attrs;
|
|
91
|
+
return rest;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const rootClasses = computed(() => mergeClasses(
|
|
95
|
+
'afcl-code-input relative inline-flex items-center gap-2',
|
|
96
|
+
props.disabled && 'opacity-50',
|
|
97
|
+
attrs.class as ClassNameValue,
|
|
98
|
+
));
|
|
99
|
+
|
|
100
|
+
const activeIndex = computed(() => isFocused.value ? Math.min(code.value.length, props.length - 1) : -1);
|
|
101
|
+
|
|
102
|
+
function cellClasses(index: number) {
|
|
103
|
+
const isActive = index === activeIndex.value;
|
|
104
|
+
return [
|
|
105
|
+
'afcl-code-input-cell flex items-center justify-center w-11 h-12 shrink-0 rounded-default border',
|
|
106
|
+
'bg-lightInputBackground text-xl font-medium tabular-nums text-lightInputText shadow-customLight transition-[border-color,box-shadow]',
|
|
107
|
+
'dark:bg-darkInputBackground dark:text-darkInputText',
|
|
108
|
+
props.invalid
|
|
109
|
+
? 'border-lightDanger dark:border-darkDanger'
|
|
110
|
+
: 'border-lightInputBorder dark:border-darkInputBorder',
|
|
111
|
+
isActive && (props.invalid
|
|
112
|
+
? 'ring-[3px] ring-lightDanger/20 dark:ring-darkDanger/30'
|
|
113
|
+
: 'border-lightInputFocusBorder ring-[3px] ring-lightInputFocusRing dark:border-darkInputFocusBorder dark:ring-darkInputFocusRing'),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isGroupEnd(position: number) {
|
|
118
|
+
return props.groupSize > 0 && position % props.groupSize === 0 && position < props.length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function moveCaretToEnd() {
|
|
122
|
+
const input = inputRef.value!;
|
|
123
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setCode(raw: string) {
|
|
127
|
+
const digits = raw.replace(NON_DIGIT_RE, '').slice(0, props.length);
|
|
128
|
+
code.value = digits;
|
|
129
|
+
// dropped letters do not change the model, so the field is synced by hand
|
|
130
|
+
inputRef.value!.value = digits;
|
|
131
|
+
moveCaretToEnd();
|
|
132
|
+
if (digits.length === props.length) {
|
|
133
|
+
emit('complete', digits);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function focus() {
|
|
138
|
+
inputRef.value?.focus();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function clear() {
|
|
142
|
+
code.value = '';
|
|
143
|
+
inputRef.value!.value = '';
|
|
144
|
+
focus();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onKeydown(e: KeyboardEvent) {
|
|
148
|
+
if (CARET_KEYS.includes(e.key)) {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// a pasted code may come with spaces or a dash ("123 456"), so it is cleaned before maxlength cuts it
|
|
154
|
+
function onPaste(e: ClipboardEvent) {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
setCode(e.clipboardData?.getData('text') ?? '');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onFocus() {
|
|
160
|
+
isFocused.value = true;
|
|
161
|
+
moveCaretToEnd();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function onBlur(e: FocusEvent) {
|
|
165
|
+
isFocused.value = false;
|
|
166
|
+
const next = e.relatedTarget as Element | null;
|
|
167
|
+
if (!props.keepFocus || next?.matches(EDITABLE_SELECTOR) || (lastInteraction === 'keyboard' && next?.matches(FOCUSABLE_CONTROL_SELECTOR))) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// the clicked button still gets its click, the focus is back before the user types again
|
|
171
|
+
requestAnimationFrame(() => {
|
|
172
|
+
if (inputRef.value?.isConnected && !document.activeElement?.matches(EDITABLE_SELECTOR)) {
|
|
173
|
+
focus();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function markPointer() {
|
|
179
|
+
lastInteraction = 'pointer';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function markKeyboard() {
|
|
183
|
+
lastInteraction = 'keyboard';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function onWindowPaste(e: ClipboardEvent) {
|
|
187
|
+
const target = e.target as Element;
|
|
188
|
+
if (target === inputRef.value || target.closest(EDITABLE_SELECTOR)) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const digits = (e.clipboardData?.getData('text') ?? '').replace(NON_DIGIT_RE, '');
|
|
192
|
+
if (digits.length !== props.length) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
focus();
|
|
197
|
+
setCode(digits);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
onMounted(async () => {
|
|
201
|
+
if (props.keepFocus) {
|
|
202
|
+
window.addEventListener('paste', onWindowPaste);
|
|
203
|
+
document.addEventListener('pointerdown', markPointer, true);
|
|
204
|
+
document.addEventListener('keydown', markKeyboard, true);
|
|
205
|
+
}
|
|
206
|
+
if (props.autofocus) {
|
|
207
|
+
// a dialog shows its content after this mount and focuses its panel on the next tick
|
|
208
|
+
await nextTick();
|
|
209
|
+
requestAnimationFrame(focus);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
onBeforeUnmount(() => {
|
|
214
|
+
window.removeEventListener('paste', onWindowPaste);
|
|
215
|
+
document.removeEventListener('pointerdown', markPointer, true);
|
|
216
|
+
document.removeEventListener('keydown', markKeyboard, true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
defineExpose({ focus, clear });
|
|
220
|
+
</script>
|
|
@@ -29,8 +29,11 @@ const props = defineProps<{
|
|
|
29
29
|
target?: 'blank' | 'self' | 'parent' | 'top'
|
|
30
30
|
}>();
|
|
31
31
|
|
|
32
|
+
// any URL with a scheme leaves the SPA: http(s), mailto:, tel:, otpauth:
|
|
33
|
+
const URL_SCHEME_RE = /^[a-z][a-z\d+.-]*:/i;
|
|
34
|
+
|
|
32
35
|
const isExternal = computed(() => {
|
|
33
|
-
return typeof props.to === 'string' && props.to
|
|
36
|
+
return typeof props.to === 'string' && URL_SCHEME_RE.test(props.to);
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
const linkClasses = "afcl-link font-medium text-lightPrimary underline decoration-lightPrimary/30 underline-offset-[3px] hover:decoration-lightPrimary dark:text-darkPrimary dark:decoration-darkPrimary/40 dark:hover:decoration-darkPrimary cursor-pointer transition-colors";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ClassNameValue } from 'tailwind-merge';
|
|
2
2
|
import { mergeClasses } from './classMerge';
|
|
3
3
|
|
|
4
|
-
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
4
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'danger-soft' | 'ghost';
|
|
5
5
|
|
|
6
6
|
export type ButtonSize = 'md' | 'sm';
|
|
7
7
|
|
|
@@ -36,6 +36,12 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|
|
36
36
|
border-transparent
|
|
37
37
|
`,
|
|
38
38
|
|
|
39
|
+
// a quieter destructive action which sits next to secondary buttons, e.g. Remove on a card
|
|
40
|
+
'danger-soft': `
|
|
41
|
+
text-lightDanger bg-lightSecondary border-lightDangerBorder focus-visible:ring-lightDanger/20
|
|
42
|
+
dark:text-darkDanger dark:bg-darkSecondary dark:border-darkDangerBorder dark:focus-visible:ring-darkDanger/30
|
|
43
|
+
`,
|
|
44
|
+
|
|
39
45
|
ghost: `
|
|
40
46
|
bg-transparent border-transparent text-lightInputIconColor focus-visible:ring-lightFocusRing
|
|
41
47
|
dark:text-darkInputIconColor dark:focus-visible:ring-darkFocusRing
|
|
@@ -56,6 +62,9 @@ const variantHoverStyles: Record<ButtonVariant, string> = {
|
|
|
56
62
|
hover:bg-lightAcceptModalConfirmButtonBackgroundHover hover:border-transparent
|
|
57
63
|
dark:hover:bg-darkAcceptModalConfirmButtonBackgroundHover
|
|
58
64
|
`,
|
|
65
|
+
'danger-soft': `
|
|
66
|
+
hover:bg-lightDangerSoft dark:hover:bg-darkDangerSoft
|
|
67
|
+
`,
|
|
59
68
|
ghost: `
|
|
60
69
|
hover:bg-lightSecondaryDarken hover:text-lightInputText
|
|
61
70
|
dark:hover:bg-darkSecondaryLighten dark:hover:text-darkInputText
|
|
@@ -4,6 +4,7 @@ export { default as Select } from './Select.vue';
|
|
|
4
4
|
export { default as Link } from './Link.vue';
|
|
5
5
|
export { default as Button } from './Button.vue';
|
|
6
6
|
export { default as Input } from './Input.vue';
|
|
7
|
+
export { default as CodeInput } from './CodeInput.vue';
|
|
7
8
|
export { default as Tooltip } from './Tooltip.vue';
|
|
8
9
|
export { default as LinkButton } from './LinkButton.vue';
|
|
9
10
|
export { default as VerticalTabs } from './VerticalTabs.vue';
|