@meistrari/tela-build 1.41.0 → 1.42.1
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/components/tela/input-otp/input-otp-group.vue +13 -0
- package/components/tela/input-otp/input-otp-separator.vue +14 -0
- package/components/tela/input-otp/input-otp-slot.vue +95 -0
- package/components/tela/input-otp/input-otp.mdx +116 -0
- package/components/tela/input-otp/input-otp.stories.ts +138 -0
- package/components/tela/input-otp/input-otp.vue +50 -0
- package/package.json +3 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
class?: HTMLAttributes['class']
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div :class="cn('flex items-center gap-1', props.class)">
|
|
11
|
+
<slot />
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
class?: HTMLAttributes['class']
|
|
6
|
+
}>()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
:class="cn('h-0.5 w-2 rounded-full bg-border-strong', props.class)"
|
|
13
|
+
/>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SlotProps } from 'vue-input-otp'
|
|
3
|
+
import { useVueOTPContext } from 'vue-input-otp'
|
|
4
|
+
import type { HTMLAttributes } from 'vue'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<SlotProps & {
|
|
7
|
+
index: number
|
|
8
|
+
invalid?: boolean
|
|
9
|
+
class?: HTMLAttributes['class']
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const otpContext = useVueOTPContext()
|
|
13
|
+
|
|
14
|
+
const isMultiSelect = computed(() => {
|
|
15
|
+
const activeSlots = otpContext?.value.slots.filter(s => s.isActive) ?? []
|
|
16
|
+
|
|
17
|
+
return activeSlots.length > 1
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const indicatorLayoutId = computed(() =>
|
|
21
|
+
isMultiSelect.value ? `indicator-${props.index}` : 'indicator',
|
|
22
|
+
)
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<MotionConfig reduced-motion="user">
|
|
27
|
+
<div
|
|
28
|
+
:data-active="props.isActive || undefined"
|
|
29
|
+
:aria-invalid="props.invalid || undefined"
|
|
30
|
+
:class="
|
|
31
|
+
cn(
|
|
32
|
+
'group relative flex w-40px h-40px items-center justify-center',
|
|
33
|
+
'rounded-10px border-0.5px border bg',
|
|
34
|
+
'body-16-semibold text-primary',
|
|
35
|
+
'[box-shadow:0_1px_4px_0_rgba(103,127,148,0.03)]',
|
|
36
|
+
'aria-invalid:!border-red-500 aria-invalid:!text-red-600',
|
|
37
|
+
'aria-invalid:data-[active]:!ring-red-100',
|
|
38
|
+
props.class,
|
|
39
|
+
)
|
|
40
|
+
"
|
|
41
|
+
>
|
|
42
|
+
<AnimatePresence mode="wait">
|
|
43
|
+
<div
|
|
44
|
+
v-if="props.char"
|
|
45
|
+
:key="props.char"
|
|
46
|
+
class="relative flex size-[inherit] items-center justify-center overflow-hidden"
|
|
47
|
+
>
|
|
48
|
+
<Motion
|
|
49
|
+
:initial="{ opacity: 0.2, y: 20 }"
|
|
50
|
+
:animate="{ opacity: 1, y: 0 }"
|
|
51
|
+
:exit="{ opacity: 0, y: 0 }"
|
|
52
|
+
:transition="{ duration: 0.09, ease: 'easeOut' }"
|
|
53
|
+
>
|
|
54
|
+
{{ props.char }}
|
|
55
|
+
</Motion>
|
|
56
|
+
</div>
|
|
57
|
+
</AnimatePresence>
|
|
58
|
+
|
|
59
|
+
<template v-if="!props.char && props.placeholderChar !== null">
|
|
60
|
+
<span class="text-text-subtle">{{ props.placeholderChar }}</span>
|
|
61
|
+
</template>
|
|
62
|
+
|
|
63
|
+
<div
|
|
64
|
+
v-if="props.hasFakeCaret"
|
|
65
|
+
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
|
66
|
+
>
|
|
67
|
+
<div class="otp-caret h-4.5 w-px bg-neutral-600" />
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<AnimatePresence mode="wait">
|
|
71
|
+
<Motion
|
|
72
|
+
v-if="props.isActive"
|
|
73
|
+
:key="`${props.isActive}-${isMultiSelect}`"
|
|
74
|
+
:layout-id="indicatorLayoutId"
|
|
75
|
+
:transition="{ duration: 0.12, ease: 'easeInOut' }"
|
|
76
|
+
class="absolute inset-0 z-10 rounded-[inherit] ring-2 ring-border-strong"
|
|
77
|
+
:class="props.invalid && '!ring-red-500'"
|
|
78
|
+
/>
|
|
79
|
+
</AnimatePresence>
|
|
80
|
+
</div>
|
|
81
|
+
</MotionConfig>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<style scoped>
|
|
85
|
+
@keyframes otp-caret-blink {
|
|
86
|
+
0%, 70%, 100% { opacity: 1; }
|
|
87
|
+
20%, 50% { opacity: 0; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
91
|
+
.otp-caret {
|
|
92
|
+
animation: otp-caret-blink 1.2s ease-out infinite;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Meta, Canvas, ArgTypes } from '@storybook/blocks';
|
|
2
|
+
import * as InputOtpStories from './input-otp.stories.ts';
|
|
3
|
+
|
|
4
|
+
<Meta of={InputOtpStories} />
|
|
5
|
+
|
|
6
|
+
# TelaInputOtp
|
|
7
|
+
|
|
8
|
+
One-time passcode input. Renders a row of single-character slots that share one hidden input, so paste, arrow-key navigation, backspace, and mobile OTP autofill work transparently. Built on [`vue-input-otp`](https://github.com/wobsoriano/vue-input-otp). Compose with `TelaInputOtpGroup`, `TelaInputOtpSlot`, and `TelaInputOtpSeparator`.
|
|
9
|
+
|
|
10
|
+
## Rules
|
|
11
|
+
|
|
12
|
+
- The default slot of `TelaInputOtp` exposes `{ slots }` — iterate it with `v-for` and bind each entry to a `TelaInputOtpSlot` via `v-bind="slot"`. Also pass `:index` (the loop index) so the active-ring animation can track movement between slots.
|
|
13
|
+
- `:maxlength` must match the number of rendered slots.
|
|
14
|
+
- For numeric codes keep the default `inputmode="numeric"`. The hidden input also sets `autocomplete="one-time-code"`, which lets iOS and Android suggest codes from SMS.
|
|
15
|
+
- Inside a `TelaModal`, wrap the OTP in a `w-full` container — the slots themselves keep their fixed width.
|
|
16
|
+
|
|
17
|
+
## Examples
|
|
18
|
+
|
|
19
|
+
### Default
|
|
20
|
+
|
|
21
|
+
<Canvas of={InputOtpStories.Default} />
|
|
22
|
+
|
|
23
|
+
### With Separator
|
|
24
|
+
|
|
25
|
+
<Canvas of={InputOtpStories.WithSeparator} />
|
|
26
|
+
|
|
27
|
+
### Disabled
|
|
28
|
+
|
|
29
|
+
<Canvas of={InputOtpStories.Disabled} />
|
|
30
|
+
|
|
31
|
+
### Invalid
|
|
32
|
+
|
|
33
|
+
<Canvas of={InputOtpStories.Invalid} />
|
|
34
|
+
|
|
35
|
+
## Basic Usage
|
|
36
|
+
|
|
37
|
+
```vue
|
|
38
|
+
<script setup lang="ts">
|
|
39
|
+
const code = ref('')
|
|
40
|
+
|
|
41
|
+
function onComplete(value: string) {
|
|
42
|
+
submit(value)
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<TelaInputOtp
|
|
48
|
+
v-model="code"
|
|
49
|
+
v-slot="{ slots }"
|
|
50
|
+
:maxlength="6"
|
|
51
|
+
inputmode="numeric"
|
|
52
|
+
@complete="onComplete"
|
|
53
|
+
>
|
|
54
|
+
<TelaInputOtpGroup>
|
|
55
|
+
<TelaInputOtpSlot
|
|
56
|
+
v-for="(slot, idx) in slots.slice(0, 3)"
|
|
57
|
+
:key="idx"
|
|
58
|
+
v-bind="slot"
|
|
59
|
+
:index="idx"
|
|
60
|
+
/>
|
|
61
|
+
</TelaInputOtpGroup>
|
|
62
|
+
<TelaInputOtpSeparator />
|
|
63
|
+
<TelaInputOtpGroup>
|
|
64
|
+
<TelaInputOtpSlot
|
|
65
|
+
v-for="(slot, idx) in slots.slice(3)"
|
|
66
|
+
:key="idx + 3"
|
|
67
|
+
v-bind="slot"
|
|
68
|
+
:index="idx + 3"
|
|
69
|
+
/>
|
|
70
|
+
</TelaInputOtpGroup>
|
|
71
|
+
</TelaInputOtp>
|
|
72
|
+
</template>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Props
|
|
76
|
+
|
|
77
|
+
### TelaInputOtp
|
|
78
|
+
|
|
79
|
+
<ArgTypes />
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
type InputOtpProps = {
|
|
83
|
+
modelValue?: string
|
|
84
|
+
defaultValue?: string
|
|
85
|
+
maxlength: number
|
|
86
|
+
inputmode?: 'numeric' | 'text'
|
|
87
|
+
textAlign?: 'left' | 'center' | 'right'
|
|
88
|
+
pushPasswordManagerStrategy?: 'increase-width' | 'none'
|
|
89
|
+
pasteTransformer?: (pasted: string | undefined) => string
|
|
90
|
+
autofocus?: boolean
|
|
91
|
+
disabled?: boolean
|
|
92
|
+
containerClass?: string
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### TelaInputOtpSlot
|
|
97
|
+
|
|
98
|
+
Receives the `SlotProps` exposed by the default slot. The optional `invalid` prop turns the box red.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
type InputOtpSlotProps = {
|
|
102
|
+
index: number
|
|
103
|
+
isActive: boolean
|
|
104
|
+
char: string | null
|
|
105
|
+
placeholderChar: string | null
|
|
106
|
+
hasFakeCaret: boolean
|
|
107
|
+
invalid?: boolean
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Accessibility
|
|
112
|
+
|
|
113
|
+
- A single hidden input captures keystrokes, so password managers, autofill, and screen readers work as if there were just one field.
|
|
114
|
+
- `autocomplete="one-time-code"` is set automatically, so iOS and Android suggest codes received via SMS.
|
|
115
|
+
- Arrow keys, Home, End, Backspace, and Delete are all handled by the underlying input.
|
|
116
|
+
- Paste distributes characters across remaining slots and advances the caret.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
|
|
3
|
+
import InputOtp from './input-otp.vue'
|
|
4
|
+
import InputOtpGroup from './input-otp-group.vue'
|
|
5
|
+
import InputOtpSeparator from './input-otp-separator.vue'
|
|
6
|
+
import InputOtpSlot from './input-otp-slot.vue'
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof InputOtp> = {
|
|
9
|
+
title: 'Core/InputOtp',
|
|
10
|
+
component: InputOtp,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: 'centered',
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component: 'One-time passcode input. Renders a row of single-character slots that share one hidden input, so paste, arrow-key navigation, backspace, and mobile OTP autofill work transparently. Built on `vue-input-otp`. Compose with TelaInputOtpGroup, TelaInputOtpSlot, and TelaInputOtpSeparator.',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
modelValue: {
|
|
21
|
+
control: 'text',
|
|
22
|
+
description: 'The current OTP value as a string (v-model).',
|
|
23
|
+
},
|
|
24
|
+
maxlength: {
|
|
25
|
+
control: 'number',
|
|
26
|
+
description: 'Number of slots in the input.',
|
|
27
|
+
},
|
|
28
|
+
inputmode: {
|
|
29
|
+
control: 'select',
|
|
30
|
+
options: ['numeric', 'text'],
|
|
31
|
+
description: 'Virtual keyboard appearance on mobile.',
|
|
32
|
+
},
|
|
33
|
+
textAlign: {
|
|
34
|
+
control: 'select',
|
|
35
|
+
options: ['left', 'center', 'right'],
|
|
36
|
+
description: 'Caret behavior when the input is partially filled.',
|
|
37
|
+
},
|
|
38
|
+
autofocus: {
|
|
39
|
+
control: 'boolean',
|
|
40
|
+
description: 'Automatically focuses the hidden input on mount.',
|
|
41
|
+
},
|
|
42
|
+
disabled: {
|
|
43
|
+
control: 'boolean',
|
|
44
|
+
description: 'Disables every slot and the hidden input.',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default meta
|
|
50
|
+
|
|
51
|
+
type Story = StoryObj<typeof meta>
|
|
52
|
+
|
|
53
|
+
export const Default: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
maxlength: 6,
|
|
56
|
+
inputmode: 'numeric',
|
|
57
|
+
},
|
|
58
|
+
render: args => ({
|
|
59
|
+
components: { InputOtp, InputOtpGroup, InputOtpSlot },
|
|
60
|
+
setup() {
|
|
61
|
+
return { args }
|
|
62
|
+
},
|
|
63
|
+
template: `
|
|
64
|
+
<InputOtp v-bind="args" v-slot="{ slots }">
|
|
65
|
+
<InputOtpGroup>
|
|
66
|
+
<InputOtpSlot v-for="(slot, idx) in slots" :key="idx" v-bind="slot" :index="idx" />
|
|
67
|
+
</InputOtpGroup>
|
|
68
|
+
</InputOtp>
|
|
69
|
+
`,
|
|
70
|
+
}),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const WithSeparator: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
maxlength: 6,
|
|
76
|
+
inputmode: 'numeric',
|
|
77
|
+
},
|
|
78
|
+
render: args => ({
|
|
79
|
+
components: { InputOtp, InputOtpGroup, InputOtpSlot, InputOtpSeparator },
|
|
80
|
+
setup() {
|
|
81
|
+
return { args }
|
|
82
|
+
},
|
|
83
|
+
template: `
|
|
84
|
+
<InputOtp v-bind="args" v-slot="{ slots }">
|
|
85
|
+
<InputOtpGroup>
|
|
86
|
+
<InputOtpSlot v-for="(slot, idx) in slots.slice(0, 3)" :key="idx" v-bind="slot" :index="idx" />
|
|
87
|
+
</InputOtpGroup>
|
|
88
|
+
<InputOtpSeparator />
|
|
89
|
+
<InputOtpGroup>
|
|
90
|
+
<InputOtpSlot v-for="(slot, idx) in slots.slice(3)" :key="idx + 3" v-bind="slot" :index="idx + 3" />
|
|
91
|
+
</InputOtpGroup>
|
|
92
|
+
</InputOtp>
|
|
93
|
+
`,
|
|
94
|
+
}),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const Disabled: Story = {
|
|
98
|
+
args: {
|
|
99
|
+
maxlength: 4,
|
|
100
|
+
inputmode: 'numeric',
|
|
101
|
+
disabled: true,
|
|
102
|
+
modelValue: '1234',
|
|
103
|
+
},
|
|
104
|
+
render: args => ({
|
|
105
|
+
components: { InputOtp, InputOtpGroup, InputOtpSlot },
|
|
106
|
+
setup() {
|
|
107
|
+
return { args }
|
|
108
|
+
},
|
|
109
|
+
template: `
|
|
110
|
+
<InputOtp v-bind="args" v-slot="{ slots }">
|
|
111
|
+
<InputOtpGroup>
|
|
112
|
+
<InputOtpSlot v-for="(slot, idx) in slots" :key="idx" v-bind="slot" :index="idx" />
|
|
113
|
+
</InputOtpGroup>
|
|
114
|
+
</InputOtp>
|
|
115
|
+
`,
|
|
116
|
+
}),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const Invalid: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
maxlength: 4,
|
|
122
|
+
inputmode: 'numeric',
|
|
123
|
+
modelValue: '1234',
|
|
124
|
+
},
|
|
125
|
+
render: args => ({
|
|
126
|
+
components: { InputOtp, InputOtpGroup, InputOtpSlot },
|
|
127
|
+
setup() {
|
|
128
|
+
return { args }
|
|
129
|
+
},
|
|
130
|
+
template: `
|
|
131
|
+
<InputOtp v-bind="args" v-slot="{ slots }">
|
|
132
|
+
<InputOtpGroup>
|
|
133
|
+
<InputOtpSlot v-for="(slot, idx) in slots" :key="idx" v-bind="slot" :index="idx" invalid />
|
|
134
|
+
</InputOtpGroup>
|
|
135
|
+
</InputOtp>
|
|
136
|
+
`,
|
|
137
|
+
}),
|
|
138
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { OTPInputEmits, OTPInputProps, SlotProps } from 'vue-input-otp'
|
|
3
|
+
import type { HTMLAttributes } from 'vue'
|
|
4
|
+
import { OTPInput } from 'vue-input-otp'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<OTPInputProps & {
|
|
7
|
+
modelValue?: string
|
|
8
|
+
class?: HTMLAttributes['class']
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const emit = defineEmits<OTPInputEmits & {
|
|
12
|
+
(event: 'update:modelValue', value: string | undefined): void
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
defineSlots<{
|
|
16
|
+
default: (props: { slots: SlotProps[], isFocused: boolean, isHovering: boolean }) => unknown
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const containerClass = computed(() =>
|
|
20
|
+
cn('flex items-center gap-2 has-[:disabled]:opacity-50', props.containerClass, props.class),
|
|
21
|
+
)
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<OTPInput
|
|
26
|
+
:model-value="modelValue"
|
|
27
|
+
:maxlength="maxlength"
|
|
28
|
+
:text-align="textAlign"
|
|
29
|
+
:inputmode="inputmode"
|
|
30
|
+
:push-password-manager-strategy="pushPasswordManagerStrategy"
|
|
31
|
+
:no-script-css-fallback="noScriptCssFallback"
|
|
32
|
+
:default-value="defaultValue"
|
|
33
|
+
:paste-transformer="pasteTransformer"
|
|
34
|
+
:autofocus="autofocus"
|
|
35
|
+
:disabled="disabled"
|
|
36
|
+
:name="name"
|
|
37
|
+
:container-class="containerClass"
|
|
38
|
+
@update:model-value="value => emit('update:modelValue', value)"
|
|
39
|
+
@complete="value => emit('complete', value)"
|
|
40
|
+
@change="event => emit('change', event)"
|
|
41
|
+
@input="value => emit('input', value)"
|
|
42
|
+
@focus="event => emit('focus', event)"
|
|
43
|
+
@blur="event => emit('blur', event)"
|
|
44
|
+
@paste="event => emit('paste', event)"
|
|
45
|
+
>
|
|
46
|
+
<template #default="slotProps">
|
|
47
|
+
<slot v-bind="slotProps" />
|
|
48
|
+
</template>
|
|
49
|
+
</OTPInput>
|
|
50
|
+
</template>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meistrari/tela-build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"app.config.ts",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"number-flow": "0.4.1",
|
|
54
54
|
"nuxt": "3.17.7",
|
|
55
55
|
"pathe": "1.1.2",
|
|
56
|
+
"pdfjs-dist": "4.10.38",
|
|
56
57
|
"radix-vue": "1.9.17",
|
|
57
58
|
"reka-ui": "2.3.0",
|
|
58
59
|
"resize-observer-polyfill": "1.5.1",
|
|
@@ -62,10 +63,10 @@
|
|
|
62
63
|
"ts-morph": "22.0.0",
|
|
63
64
|
"typescript": "5.8.2",
|
|
64
65
|
"unocss": "66.5.12",
|
|
65
|
-
"pdfjs-dist": "4.10.38",
|
|
66
66
|
"vue": "3.5.13",
|
|
67
67
|
"vue-component-meta": "3.0.8",
|
|
68
68
|
"vue-docgen-api": "4.78.0",
|
|
69
|
+
"vue-input-otp": "0.3.2",
|
|
69
70
|
"vue-router": "4.5.0"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|