adminforth 4.3.1 → 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.startsWith('http');
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,12 +1,16 @@
1
1
  <template>
2
- <div class="relative w-full bg-lightProgressBarUnfilledColor rounded-full h-1.5 dark:bg-darkProgressBarUnfilledColor" :class="props.height ? `h-${props.height}` : ''">
3
- <span v-if="leftLabel" class="absolute -top-6 left-0 text-sm" :class="textClass">{{ leftLabel }}</span>
4
- <span v-if="rightLabel" class="absolute -top-6 right-0 text-sm" :class="textClass">{{ rightLabel }}</span>
5
- <div
6
- class="bg-lightPrimary dark:bg-darkPrimary h-1.5 rounded-full transition-all duration-300 ease-in-out"
7
- :class="{ 'progress-bar': showAnimation, [`h-${props.height}`]: props.height }"
8
- :style="{ width: `${percentage}%` }"
9
- ></div>
2
+ <div class="w-full">
3
+ <div v-if="leftLabel || rightLabel" class="flex justify-between mb-1 text-sm" :class="textClass">
4
+ <span>{{ leftLabel }}</span>
5
+ <span>{{ rightLabel }}</span>
6
+ </div>
7
+ <div class="w-full bg-lightProgressBarUnfilledColor rounded-full h-1.5 dark:bg-darkProgressBarUnfilledColor" :class="props.height ? `h-${props.height}` : ''">
8
+ <div
9
+ class="bg-lightPrimary dark:bg-darkPrimary h-1.5 rounded-full transition-all duration-300 ease-in-out"
10
+ :class="{ 'progress-bar': showAnimation, [`h-${props.height}`]: props.height }"
11
+ :style="{ width: `${percentage}%` }"
12
+ ></div>
13
+ </div>
10
14
  <div v-if="showValues || showProgress" class="flex justify-between mt-2">
11
15
  <span v-if="showValues" class="text-sm" :class="textClass">{{ formatValue(minValue) }}</span>
12
16
  <span v-if="showProgress" class="text-sm" :class="textClass">{{ progressText }}</span>
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adminforth",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
4
4
  "description": "OpenSource Agent-Native forth-generation admin panel",
5
5
  "keywords": [
6
6
  "adminforth",