@unlk/keymaster 1.6.6 → 1.6.8

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/js/bootstrap.js CHANGED
@@ -15,3 +15,4 @@ export { default as CarouselCaption } from './carousel-caption';
15
15
  export { default as CarouselHeight } from './carousel-height';
16
16
  export { default as VideoModal } from './video-modal';
17
17
  export { default as AccordionScroll } from './accordion-scroll';
18
+ export { default as OtpInput } from './otp-input';
@@ -0,0 +1,199 @@
1
+ import BaseComponent from 'bootstrap/js/src/base-component.js';
2
+ import EventHandler from 'bootstrap/js/src/dom/event-handler.js';
3
+ import SelectorEngine from 'bootstrap/js/src/dom/selector-engine.js';
4
+ import { defineJQueryPlugin } from 'bootstrap/js/src/util/index.js';
5
+
6
+ const NAME = 'otpInput';
7
+ const DATA_KEY = 'bs.otpInput';
8
+ const EVENT_KEY = `.${DATA_KEY}`;
9
+
10
+ const EVENT_INPUT = `input${EVENT_KEY}`;
11
+ const EVENT_COMPLETE = `complete${EVENT_KEY}`;
12
+
13
+ const SELECTOR_OTP = '[data-bs-otp]';
14
+ const SELECTOR_INPUT = '.otp-input';
15
+ const CLASS_SLOTS = 'otp-slots';
16
+ const CLASS_GROUP = 'otp-group';
17
+ const CLASS_SLOT = 'otp-slot';
18
+ const CLASS_CHAR = 'otp-char';
19
+ const CLASS_CURSOR = 'otp-cursor';
20
+ const CLASS_SEPARATOR = 'otp-separator';
21
+ const CLASS_SLOT_ACTIVE = 'otp-slot-active';
22
+ const CLASS_SLOT_FILLED = 'otp-slot-filled';
23
+ const CLASS_DISABLED = 'disabled';
24
+
25
+ const PATTERNS = {
26
+ numeric: /\D/g,
27
+ alpha: /[^a-zA-Z]/g,
28
+ alphanumeric: /[^a-zA-Z0-9]/g
29
+ };
30
+
31
+ const INPUT_PATTERNS = {
32
+ numeric: '[0-9]',
33
+ alpha: '[a-zA-Z]',
34
+ alphanumeric: '[a-zA-Z0-9]'
35
+ };
36
+
37
+ class OtpInput extends BaseComponent {
38
+ constructor(element, config) {
39
+ super(element, config);
40
+
41
+ this._input = SelectorEngine.findOne(SELECTOR_INPUT, this._element);
42
+ if (!this._input) return;
43
+
44
+ this._length = this._input.maxLength > 0 ? this._input.maxLength : 6;
45
+ this._type = this._element.dataset.bsType || 'numeric';
46
+ const charPattern = INPUT_PATTERNS[this._type] || INPUT_PATTERNS.numeric;
47
+ this._input.pattern = `${charPattern}{${this._length}}`;
48
+ this._mask = this._element.dataset.bsMask === 'true';
49
+ this._groups = this._element.dataset.bsGroups
50
+ ? String(this._element.dataset.bsGroups).split(',').map(Number)
51
+ : [this._length];
52
+
53
+ this._slots = [];
54
+ this._buildSlots();
55
+ this._bindEvents();
56
+ this._render();
57
+ }
58
+
59
+ // Public API
60
+
61
+ getValue() {
62
+ return this._input.value;
63
+ }
64
+
65
+ setValue(value) {
66
+ const pattern = PATTERNS[this._type] || PATTERNS.numeric;
67
+ this._input.value = String(value).replace(pattern, '').slice(0, this._length);
68
+ this._render();
69
+ }
70
+
71
+ clear() {
72
+ this._input.value = '';
73
+ this._render();
74
+ this._input.focus();
75
+ }
76
+
77
+ // Private
78
+
79
+ _buildSlots() {
80
+ // Hide real input visually but keep it accessible
81
+ this._input.style.cssText =
82
+ 'position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;overflow:hidden;white-space:nowrap;';
83
+
84
+ const slotsWrapper = document.createElement('div');
85
+ slotsWrapper.classList.add(CLASS_SLOTS);
86
+ slotsWrapper.setAttribute('aria-hidden', 'true');
87
+
88
+ let slotIndex = 0;
89
+ for (const [gi, groupLen] of this._groups.entries()) {
90
+ const groupEl = document.createElement('div');
91
+ groupEl.classList.add(CLASS_GROUP);
92
+
93
+ for (let i = 0; i < groupLen; i++) {
94
+ const slot = document.createElement('div');
95
+ slot.classList.add(CLASS_SLOT);
96
+ slot.dataset.index = slotIndex;
97
+
98
+ const char = document.createElement('span');
99
+ char.classList.add(CLASS_CHAR);
100
+
101
+ const cursor = document.createElement('span');
102
+ cursor.classList.add(CLASS_CURSOR);
103
+
104
+ slot.append(char, cursor);
105
+ groupEl.append(slot);
106
+ this._slots.push({ slot, char });
107
+ slotIndex++;
108
+ }
109
+
110
+ slotsWrapper.append(groupEl);
111
+
112
+ if (gi < this._groups.length - 1) {
113
+ const sep = document.createElement('span');
114
+ sep.classList.add(CLASS_SEPARATOR);
115
+ sep.textContent = '–';
116
+ slotsWrapper.append(sep);
117
+ }
118
+ }
119
+
120
+ this._element.append(slotsWrapper);
121
+ this._slotsWrapper = slotsWrapper;
122
+ }
123
+
124
+ _bindEvents() {
125
+ EventHandler.on(this._slotsWrapper, 'click', () => {
126
+ if (this._input.disabled) return;
127
+ this._input.focus();
128
+ const len = this._input.value.length;
129
+ this._input.setSelectionRange(len, len);
130
+ });
131
+
132
+ EventHandler.on(this._input, 'focus', () => this._render());
133
+ EventHandler.on(this._input, 'blur', () => this._render());
134
+
135
+ EventHandler.on(this._input, 'input', () => {
136
+ const pattern = PATTERNS[this._type] || PATTERNS.numeric;
137
+ this._input.value = this._input.value.replace(pattern, '').slice(0, this._length);
138
+ this._render();
139
+
140
+ EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value });
141
+
142
+ if (this._input.value.length === this._length) {
143
+ EventHandler.trigger(this._element, EVENT_COMPLETE, { value: this._input.value });
144
+ }
145
+ });
146
+
147
+ EventHandler.on(this._input, 'keydown', (e) => {
148
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
149
+ requestAnimationFrame(() => this._render());
150
+ }
151
+ });
152
+ }
153
+
154
+ _render() {
155
+ const val = this._input.value;
156
+ const focused = document.activeElement === this._input;
157
+ const activeIdx = focused ? Math.min(val.length, this._length - 1) : -1;
158
+ const isDisabled = this._input.disabled;
159
+
160
+ this._slotsWrapper.classList.toggle(CLASS_DISABLED, isDisabled);
161
+
162
+ for (const [i, { slot, char }] of this._slots.entries()) {
163
+ const ch = val[i] || '';
164
+ char.textContent = this._mask && ch ? '•' : ch;
165
+ slot.classList.toggle(CLASS_SLOT_ACTIVE, i === activeIdx && !isDisabled);
166
+ slot.classList.toggle(CLASS_SLOT_FILLED, Boolean(ch));
167
+ }
168
+ }
169
+
170
+ // Static
171
+
172
+ static get NAME() {
173
+ return NAME;
174
+ }
175
+
176
+ static jQueryInterface(config, ...args) {
177
+ return this.each(function () {
178
+ const data = OtpInput.getOrCreateInstance(this);
179
+ if (typeof config === 'string') {
180
+ if (typeof data[config] === 'undefined') {
181
+ throw new TypeError(`No method named "${config}"`);
182
+ }
183
+
184
+ data[config](...args);
185
+ }
186
+ });
187
+ }
188
+ }
189
+
190
+ // Auto-init on DOMContentLoaded
191
+ EventHandler.on(globalThis, 'load', () => {
192
+ for (const el of SelectorEngine.find(SELECTOR_OTP)) {
193
+ OtpInput.getOrCreateInstance(el);
194
+ }
195
+ });
196
+
197
+ defineJQueryPlugin(OtpInput);
198
+
199
+ export default OtpInput;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlk/keymaster",
3
- "version": "1.6.6",
3
+ "version": "1.6.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/react/index.js CHANGED
@@ -3,3 +3,4 @@ export { useCarouselCaption } from './use-carousel-caption.js';
3
3
  export { useCarouselHeight } from './use-carousel-height.js';
4
4
  export { useVideoModal } from './use-video-modal.js';
5
5
  export { useAccordionScroll } from './use-accordion-scroll.js';
6
+ export { useOtpInput } from './use-otp-input.js';
@@ -0,0 +1,129 @@
1
+ import { useState, useRef, useCallback, useId } from 'react';
2
+
3
+ const PATTERNS = {
4
+ numeric: /\D/g,
5
+ alpha: /[^a-zA-Z]/g,
6
+ alphanumeric: /[^a-zA-Z0-9]/g,
7
+ };
8
+
9
+ /**
10
+ * Replicates otp-input.js behaviour in React.
11
+ *
12
+ * Manages a single hidden input whose value is rendered as individual
13
+ * character slots, matching the keymaster OTP plugin's visual output.
14
+ *
15
+ * @param {object} options
16
+ * @param {number} [options.length=6] Number of character slots.
17
+ * @param {'numeric'|'alphanumeric'|'alpha'} [options.type='numeric']
18
+ * @param {boolean} [options.mask=false] Obscure entered characters.
19
+ * @param {function} [options.onComplete] Called with the value when all slots are filled.
20
+ * @param {function} [options.onChange] Called with the value on every change.
21
+ *
22
+ * Usage:
23
+ * const otp = useOtpInput({ length: 6, onComplete: (val) => console.log(val) });
24
+ *
25
+ * <div className="otp-wrapper">
26
+ * <label className="form-label">One-time password</label>
27
+ * <div className="otp" onClick={otp.focusInput}>
28
+ * <input {...otp.inputProps} />
29
+ * <div className="otp-slots" aria-hidden="true">
30
+ * {otp.slots.map((slot, i) => (
31
+ * <div key={i} className="otp-group">
32
+ * <div className={otp.slotClassName(i)}>
33
+ * <span className="otp-char">{slot.display}</span>
34
+ * {slot.active && <span className="otp-cursor" />}
35
+ * </div>
36
+ * </div>
37
+ * ))}
38
+ * </div>
39
+ * </div>
40
+ * </div>
41
+ */
42
+ export function useOtpInput({
43
+ length = 6,
44
+ type = 'numeric',
45
+ mask = false,
46
+ onComplete,
47
+ onChange,
48
+ } = {}) {
49
+ const [value, setValue] = useState('');
50
+ const [focused, setFocused] = useState(false);
51
+ const inputRef = useRef(null);
52
+ const id = useId();
53
+ const pattern = PATTERNS[type] || PATTERNS.numeric;
54
+
55
+ const handleInput = useCallback(
56
+ (e) => {
57
+ const next = e.target.value.replace(pattern, '').slice(0, length);
58
+ setValue(next);
59
+ onChange?.(next);
60
+ if (next.length === length) onComplete?.(next);
61
+ },
62
+ [length, pattern, onChange, onComplete]
63
+ );
64
+
65
+ const focusInput = useCallback(() => {
66
+ inputRef.current?.focus();
67
+ }, []);
68
+
69
+ const clear = useCallback(() => {
70
+ setValue('');
71
+ inputRef.current?.focus();
72
+ }, []);
73
+
74
+ // Derive per-slot display data
75
+ const activeIdx = focused ? Math.min(value.length, length - 1) : -1;
76
+ const slots = Array.from({ length }, (_, i) => {
77
+ const ch = value[i] || '';
78
+ return {
79
+ char: ch,
80
+ display: mask && ch ? '•' : ch,
81
+ active: i === activeIdx,
82
+ filled: Boolean(ch),
83
+ };
84
+ });
85
+
86
+ const slotClassName = useCallback(
87
+ (i) => {
88
+ const s = slots[i];
89
+ return [
90
+ 'otp-slot',
91
+ s?.active ? 'otp-slot-active' : '',
92
+ s?.filled ? 'otp-slot-filled' : '',
93
+ ]
94
+ .filter(Boolean)
95
+ .join(' ');
96
+ },
97
+ [slots]
98
+ );
99
+
100
+ const inputProps = {
101
+ ref: inputRef,
102
+ type: 'text',
103
+ className: 'otp-input',
104
+ id,
105
+ value,
106
+ maxLength: length,
107
+ autoComplete: 'one-time-code',
108
+ inputMode: type === 'numeric' ? 'numeric' : 'text',
109
+ onInput: handleInput,
110
+ // Keep value in sync when controlled
111
+ onChange(e) {
112
+ const next = e.target.value.replace(pattern, '').slice(0, length);
113
+ setValue(next);
114
+ },
115
+ onFocus: () => setFocused(true),
116
+ onBlur: () => setFocused(false),
117
+ style: {
118
+ position: 'absolute',
119
+ opacity: 0,
120
+ pointerEvents: 'none',
121
+ width: 1,
122
+ height: 1,
123
+ overflow: 'hidden',
124
+ whiteSpace: 'nowrap',
125
+ },
126
+ };
127
+
128
+ return { value, slots, inputProps, slotClassName, focusInput, clear, inputId: id };
129
+ }
@@ -91,6 +91,8 @@
91
91
  }
92
92
 
93
93
  &.btn-lg {
94
+ --#{$prefix}btn-padding-x: #{$spacer * .75};
95
+ --#{$prefix}btn-padding-y: #{$spacer * .75};
94
96
  width: rfs-value(48px);
95
97
  height: rfs-value(48px);
96
98
  }
@@ -2,3 +2,4 @@
2
2
  @import "forms/form-control";
3
3
  @import "forms/form-check";
4
4
  @import "forms/floating-labels";
5
+ @import "forms/otp";
@@ -1,2 +1,2 @@
1
1
  // GENERATED FILE – do not edit manually
2
- $km-version: "1.6.6" !default;
2
+ $km-version: "1.6.8" !default;
@@ -0,0 +1,143 @@
1
+ // OTP Input — single hidden <input> rendered as visual character slots.
2
+ // Slots deliberately match .form-control styling using the same $input-* tokens.
3
+
4
+ // Wrapper that holds the label + .otp container
5
+ .otp-wrapper {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: .375rem;
9
+ align-items: flex-start;
10
+ }
11
+
12
+ .otp {
13
+ position: relative;
14
+ display: inline-block;
15
+
16
+ // The real input — hidden but accessible
17
+ .otp-input {
18
+ position: absolute;
19
+ width: 1px;
20
+ height: 1px;
21
+ overflow: hidden;
22
+ white-space: nowrap;
23
+ pointer-events: none;
24
+ opacity: 0;
25
+ }
26
+
27
+ // Visual slot container
28
+ .otp-slots {
29
+ display: flex;
30
+ gap: .375rem;
31
+ align-items: center;
32
+ cursor: text;
33
+ user-select: none;
34
+ }
35
+
36
+ // Each digit group (used when data-bs-groups is set)
37
+ .otp-group {
38
+ display: flex;
39
+ gap: .375rem;
40
+ }
41
+
42
+ // Individual character slot — same look as .form-control
43
+ .otp-slot {
44
+ display: flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ width: $input-height; // square cell using the standard input height
48
+ height: $input-height;
49
+ font-family: $input-font-family;
50
+ font-size: $input-font-size;
51
+ font-weight: $font-weight-semibold;
52
+ color: $input-color;
53
+ background-color: $input-bg;
54
+ border: $input-border-width solid $input-border-color;
55
+ @include border-radius($input-border-radius);
56
+ @include transition(border-color .15s ease-in-out, box-shadow .15s ease-in-out);
57
+
58
+ &.otp-slot-active {
59
+ border-color: $input-focus-border-color;
60
+ outline: 0;
61
+ box-shadow: $input-focus-box-shadow;
62
+
63
+ .otp-cursor {
64
+ animation: otp-blink 1s step-end infinite;
65
+ }
66
+ }
67
+
68
+ &.otp-slot-filled .otp-cursor {
69
+ display: none;
70
+ }
71
+ }
72
+
73
+ .otp-char {
74
+ line-height: 1;
75
+ }
76
+
77
+ // Blinking text cursor
78
+ .otp-cursor {
79
+ position: absolute;
80
+ width: 1px;
81
+ height: 1.25em;
82
+ background-color: currentcolor;
83
+ opacity: 0;
84
+ }
85
+
86
+ // Separator between groups
87
+ .otp-separator {
88
+ margin: 0 .25rem;
89
+ font-size: $input-font-size;
90
+ line-height: $input-height;
91
+ color: var(--#{$prefix}secondary-color);
92
+ }
93
+
94
+ // Disabled state — mirrors .form-control:disabled
95
+ &:has(.otp-slots.disabled) {
96
+ .otp-slots {
97
+ pointer-events: none;
98
+ cursor: default;
99
+ }
100
+
101
+ .otp-slot {
102
+ color: $input-disabled-color;
103
+ background-color: $input-disabled-bg;
104
+ border-color: $input-disabled-border-color;
105
+ }
106
+ }
107
+
108
+ // Small — matches .form-control-sm
109
+ &.otp-sm {
110
+ .otp-slot {
111
+ width: $input-height-sm;
112
+ height: $input-height-sm;
113
+ font-size: $input-font-size-sm;
114
+ @include border-radius($input-border-radius-sm);
115
+ }
116
+
117
+ .otp-separator {
118
+ font-size: $input-font-size-sm;
119
+ line-height: $input-height-sm;
120
+ }
121
+ }
122
+
123
+ // Large — matches .form-control-lg
124
+ &.otp-lg {
125
+ .otp-slot {
126
+ width: $input-height-lg;
127
+ height: $input-height-lg;
128
+ font-size: $input-font-size-lg;
129
+ @include border-radius($input-border-radius-lg);
130
+ }
131
+
132
+ .otp-separator {
133
+ font-size: $input-font-size-lg;
134
+ line-height: $input-height-lg;
135
+ }
136
+ }
137
+ }
138
+
139
+ @keyframes otp-blink {
140
+ 0%,
141
+ 100% { opacity: 1; }
142
+ 50% { opacity: 0; }
143
+ }
@@ -1,6 +1,11 @@
1
1
  @each $state, $data in $form-validation-states {
2
2
  $color: map-get($data, color);
3
- $border-color: $color;
3
+ $border-color: map-get($data, border-color);
4
+ $focus-box-shadow: map-get($data, focus-box-shadow);
4
5
  @include form-check-tiled-validation-state($state, $color, $border-color);
5
6
  @include form-check-encapsulated-validation-state($state, $color);
7
+
8
+ .otp {
9
+ @include otp-validation-state($state, $border-color, $focus-box-shadow);
10
+ }
6
11
  }
@@ -1,3 +1,20 @@
1
+ @mixin otp-validation-state($state, $border-color, $focus-box-shadow) {
2
+ // pattern attribute ensures :valid only fires when all N chars match,
3
+ // so native :valid/:invalid + :user-valid/:user-invalid work correctly for both states.
4
+ .was-validated &:has(.otp-input:#{$state}),
5
+ &:has(.otp-input:user-#{$state}),
6
+ &:has(.otp-input.is-#{$state}) {
7
+ .otp-slot {
8
+ border-color: $border-color;
9
+ }
10
+
11
+ .otp-slot.otp-slot-active {
12
+ border-color: $border-color;
13
+ box-shadow: $focus-box-shadow;
14
+ }
15
+ }
16
+ }
17
+
1
18
  @mixin form-check-tiled-validation-state($state, $color, $border-color: $color) {
2
19
  .form-check.form-check-tiled > .form-check-input {
3
20
  @include form-validation-state-selector($state) {