Skip to content

Commit b60615e

Browse files
committed
feat: AFCL CodeInput for one-time codes, danger-soft Button variant, Link opens any URL with a scheme like mailto:, tel: or otpauth: as external AdminForth/2005
1 parent d7e6a3a commit b60615e

7 files changed

Lines changed: 329 additions & 6 deletions

File tree

‎adminforth/design/adoption.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Which packages follow the [design concepts](./system-concepts.md): tokens instea
3333
| @adminforth/quick-filters | ✓ |
3434
| @adminforth/rich-editor | |
3535
| @adminforth/text-complete | ✓ |
36-
| @adminforth/two-factors-auth | |
36+
| @adminforth/two-factors-auth | ✓ |
3737
| @adminforth/universal-search | ✓ |
3838
| @adminforth/upload | |
3939
| @adminforth/user-sessions | ✓ |
@@ -54,5 +54,5 @@ Which packages follow the [design concepts](./system-concepts.md): tokens instea
5454

5555
- If simple - get of flowbite, prefer pure AFCL + Tailwind, move features we luck to AFCL
5656
- Existing icon fonts apart lucide - try to get rid of them if not complex
57-
- Core uses only Lucide icons and `flag-icons` flags (`afcl/CountryFlag.vue`). `vue-flowbite` and `vue-heroicons` stay in `spa/package.json` while plugins import them (flowbite: bulk-ai-flow, clone-row, email-invite, email-password-reset, foreign-inline-list, inline-create, json-editor, markdown, open-signup, two-factors-auth, user-soft-delete, and dashboard only for `flowbite:chart-pie-solid`, the old default menu icon which stored dashboard configs may name; heroicons: markdown). After plugins rework it they should disappear.
57+
- Core uses only Lucide icons and `flag-icons` flags (`afcl/CountryFlag.vue`). `vue-flowbite` and `vue-heroicons` stay in `spa/package.json` while plugins import them (flowbite: bulk-ai-flow, clone-row, email-invite, email-password-reset, foreign-inline-list, inline-create, json-editor, markdown, open-signup, user-soft-delete, and dashboard only for `flowbite:chart-pie-solid`, the old default menu icon which stored dashboard configs may name; heroicons: markdown). After plugins rework it they should disappear.
5858
- i18n shows language flags with its own `flag-icon-css`, a 445 KB stylesheet loaded on every page: move `LanguageFlag.vue` to AFCL `CountryFlag`.

‎adminforth/design/system.html‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,15 @@
190190
.input.is-error { border-color: var(--danger); box-shadow: 0 0 0 3px var(--danger-ring); }
191191
.input.is-disabled { background: var(--surface-2); color: var(--faint); box-shadow: none; }
192192
.input.is-disabled > input { color: var(--faint); }
193+
/* CodeInput — one field drawn as digit cells */
194+
.code { display: inline-flex; align-items: center; gap: 8px; }
195+
.code-cell { display: flex; align-items: center; justify-content: center; width: 44px; height: 48px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--surface); color: var(--text); font-size: 20px; font-weight: 500; font-variant-numeric: tabular-nums; box-shadow: var(--shadow-xs); }
196+
.code-cell.is-focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring); }
197+
.code.is-error .code-cell { border-color: var(--danger); }
198+
.code.is-error .code-cell.is-focus { box-shadow: 0 0 0 3px var(--danger-ring); }
199+
.code.is-disabled { opacity: .5; }
200+
.code-sep { width: 12px; height: 2px; border-radius: 999px; background: var(--border-strong); }
201+
.code-caret { width: 1px; height: 24px; background: var(--text); }
193202
.input .i { color: var(--muted); }
194203
.affix { color: var(--muted); font-size: 13px; white-space: nowrap; }
195204
.affix-box { align-self: stretch; display: flex; align-items: center; padding: 0 10px; margin: 0 -10px; background: var(--surface-2); color: var(--muted); font-size: 13px; }
@@ -1653,8 +1662,11 @@ <h1 style="font-size: 32px; line-height: 38px; font-weight: 600; letter-spacing:
16531662
<button class="btn btn-danger" type="button">Delete</button>
16541663
<button class="btn btn-danger is-disabled" type="button">Delete</button>
16551664
</div>
1665+
<div class="row">
1666+
<button class="btn btn-danger-soft" type="button">Remove</button>
1667+
</div>
16561668
</div>
1657-
<div class="tile-cap"><b>Danger</b><code>variant="danger"</code></div>
1669+
<div class="tile-cap"><b>Danger</b><code>variant="danger" · "danger-soft"</code></div>
16581670
</div>
16591671
<div class="tile">
16601672
<div class="tile-stage">
@@ -1911,6 +1923,31 @@ <h1 style="font-size: 32px; line-height: 38px; font-weight: 600; letter-spacing:
19111923
</div>
19121924
</section>
19131925

1926+
<section class="sec">
1927+
<div class="sec-head"><span class="sec-name">&lt;CodeInput&gt;</span><span class="t-sm t-muted">one field drawn as cells · autofill and paste fill the whole code</span></div>
1928+
<div class="grid3">
1929+
<div class="tile">
1930+
<div class="tile-stage center"><div class="code" role="img" aria-label="Empty code, focused"><span class="code-cell is-focus"><span class="code-caret"></span></span><span class="code-cell"></span><span class="code-cell"></span><span class="code-sep"></span><span class="code-cell"></span><span class="code-cell"></span><span class="code-cell"></span></div></div>
1931+
<div class="tile-cap"><b>Focus</b><code>autofocus · groupSize="3"</code></div>
1932+
</div>
1933+
<div class="tile">
1934+
<div class="tile-stage center"><div class="code" role="img" aria-label="Code being typed"><span class="code-cell">4</span><span class="code-cell">8</span><span class="code-cell">1</span><span class="code-sep"></span><span class="code-cell is-focus"><span class="code-caret"></span></span><span class="code-cell"></span><span class="code-cell"></span></div></div>
1935+
<div class="tile-cap"><b>Typing</b><code>v-model · @complete</code></div>
1936+
</div>
1937+
<div class="tile">
1938+
<div class="tile-stage center">
1939+
<div class="code is-error" role="img" aria-label="Wrong code"><span class="code-cell is-focus"><span class="code-caret"></span></span><span class="code-cell"></span><span class="code-cell"></span><span class="code-sep"></span><span class="code-cell"></span><span class="code-cell"></span><span class="code-cell"></span></div>
1940+
<span class="err">Wrong or expired code</span>
1941+
</div>
1942+
<div class="tile-cap"><b>Error</b><code>invalid · clear()</code></div>
1943+
</div>
1944+
<div class="tile">
1945+
<div class="tile-stage center"><div class="code is-disabled" role="img" aria-label="Disabled code"><span class="code-cell">4</span><span class="code-cell">8</span><span class="code-cell">1</span><span class="code-sep"></span><span class="code-cell">2</span><span class="code-cell">0</span><span class="code-cell">9</span></div></div>
1946+
<div class="tile-cap"><b>Disabled</b><code>disabled</code></div>
1947+
</div>
1948+
</div>
1949+
</section>
1950+
19141951
<section class="sec">
19151952
<div class="sec-head"><span class="sec-name">&lt;Select&gt;</span><span class="t-sm t-muted">single · multiple · searchable · foreign resource</span></div>
19161953
<div class="grid5">
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

‎adminforth/spa/src/afcl/Link.vue‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ const props = defineProps<{
2929
target?: 'blank' | 'self' | 'parent' | 'top'
3030
}>();
3131
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+
3235
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);
3437
});
3538
3639
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";

‎adminforth/spa/src/afcl/buttonStyles.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ClassNameValue } from 'tailwind-merge';
22
import { mergeClasses } from './classMerge';
33

4-
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
4+
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'danger-soft' | 'ghost';
55

66
export type ButtonSize = 'md' | 'sm';
77

@@ -36,6 +36,12 @@ const variantStyles: Record<ButtonVariant, string> = {
3636
border-transparent
3737
`,
3838

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+
3945
ghost: `
4046
bg-transparent border-transparent text-lightInputIconColor focus-visible:ring-lightFocusRing
4147
dark:text-darkInputIconColor dark:focus-visible:ring-darkFocusRing
@@ -56,6 +62,9 @@ const variantHoverStyles: Record<ButtonVariant, string> = {
5662
hover:bg-lightAcceptModalConfirmButtonBackgroundHover hover:border-transparent
5763
dark:hover:bg-darkAcceptModalConfirmButtonBackgroundHover
5864
`,
65+
'danger-soft': `
66+
hover:bg-lightDangerSoft dark:hover:bg-darkDangerSoft
67+
`,
5968
ghost: `
6069
hover:bg-lightSecondaryDarken hover:text-lightInputText
6170
dark:hover:bg-darkSecondaryLighten dark:hover:text-darkInputText

‎adminforth/spa/src/afcl/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { default as Select } from './Select.vue';
44
export { default as Link } from './Link.vue';
55
export { default as Button } from './Button.vue';
66
export { default as Input } from './Input.vue';
7+
export { default as CodeInput } from './CodeInput.vue';
78
export { default as Tooltip } from './Tooltip.vue';
89
export { default as LinkButton } from './LinkButton.vue';
910
export { default as VerticalTabs } from './VerticalTabs.vue';

0 commit comments

Comments
 (0)