@pure-ds/core 0.7.62 → 0.7.63

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.
@@ -11,7 +11,7 @@ export const defaultPDSEnhancerMetadata = [
11
11
  selector: ".accordion",
12
12
  description:
13
13
  "Ensures only one <details> element can be open at a time within the accordion.",
14
- demoHtml: `
14
+ demoHtml: /*html*/`
15
15
  <div class="accordion">
16
16
  <details>
17
17
  <summary>Section 1</summary>
@@ -46,7 +46,7 @@ export const defaultPDSEnhancerMetadata = [
46
46
  appliesTo: "Any clickable element inside nav[data-dropdown] menu/panel content",
47
47
  },
48
48
  ],
49
- demoHtml: `
49
+ demoHtml: /*html*/`
50
50
  <nav data-dropdown>
51
51
  <button class="btn-primary">Menu</button>
52
52
  <menu>
@@ -59,7 +59,7 @@ export const defaultPDSEnhancerMetadata = [
59
59
  {
60
60
  selector: "label[data-toggle]",
61
61
  description: "Creates a toggle switch element from a checkbox.",
62
- demoHtml: `
62
+ demoHtml: /*html*/`
63
63
  <label data-toggle>
64
64
  <input type="checkbox">
65
65
  <span data-label>Enable notifications</span>
@@ -70,17 +70,66 @@ export const defaultPDSEnhancerMetadata = [
70
70
  selector: "label[data-color]",
71
71
  description:
72
72
  "Wraps color inputs with a styled control shell and synced hex output while keeping the native picker.",
73
- demoHtml: `
73
+ demoHtml: /*html*/`
74
74
  <label data-color>
75
75
  <span>Brand color</span>
76
76
  <input type="color" value="#7c3aed">
77
77
  </label>
78
78
  `.trim(),
79
79
  },
80
+ {
81
+ selector: 'input[autocomplete="one-time-code"]',
82
+ description:
83
+ "Enhances a single text input into a segmented one-time-code / OTP field with numeric sanitizing, full-code paste support, and optional auto-submit when the expected length is reached.",
84
+ attributes: [
85
+ {
86
+ name: "data-otp-length",
87
+ description:
88
+ "Expected code length. Defaults to the input maxlength, or 6 when neither is provided.",
89
+ appliesTo: 'input[autocomplete="one-time-code"]',
90
+ },
91
+ {
92
+ name: 'data-otp-autosubmit="false"',
93
+ description:
94
+ "Opt out of automatically submitting the parent form when the code is complete.",
95
+ appliesTo: 'input[autocomplete="one-time-code"]',
96
+ },
97
+ {
98
+ name: "data-otp-submit-selector",
99
+ description:
100
+ "Optional selector for the preferred submit button passed to form.requestSubmit().",
101
+ appliesTo: 'input[autocomplete="one-time-code"]',
102
+ },
103
+ ],
104
+ demoHtml: /*html*/`
105
+ <form action="#verify" method="post">
106
+ <label>
107
+ <span data-label>Email address</span>
108
+ <input type="email" autocomplete="email" value="marc@example.com">
109
+ </label>
110
+ <label>
111
+ <span data-label>Verification code</span>
112
+ <input
113
+ autocomplete="one-time-code"
114
+ inputmode="numeric"
115
+ maxlength="6"
116
+ data-otp-length="6"
117
+ aria-describedby="otp-help"
118
+ >
119
+ </label>
120
+ <small id="otp-help" class="field-description">
121
+ We sent a 6-digit code to marc@example.com. You can paste the full code.
122
+ </small>
123
+ <nav class="form-actions">
124
+ <button type="submit" class="btn-primary">Verify</button>
125
+ </nav>
126
+ </form>
127
+ `.trim(),
128
+ },
80
129
  {
81
130
  selector: 'input[type="range"]',
82
131
  description: "Enhances range inputs with an attached <output>.",
83
- demoHtml: `
132
+ demoHtml: /*html*/`
84
133
  <label class="range-output">
85
134
  <span data-label>Volume</span>
86
135
  <input type="range" min="0" max="100" value="40">
@@ -91,7 +140,7 @@ export const defaultPDSEnhancerMetadata = [
91
140
  selector: "form[data-required]",
92
141
  description:
93
142
  "Enhances required form fields using an asterisk in the label.",
94
- demoHtml: `
143
+ demoHtml: /*html*/`
95
144
  <form data-required action="#" method="post">
96
145
  <label>
97
146
  <span>Field Label</span>
@@ -107,7 +156,7 @@ export const defaultPDSEnhancerMetadata = [
107
156
  selector: "fieldset[role=group][data-open]",
108
157
  description:
109
158
  "Enhances a checkbox/radio group to be open (have a way to add and remove items).",
110
- demoHtml: `
159
+ demoHtml: /*html*/`
111
160
  <fieldset role="group" data-open>
112
161
  <label>
113
162
  <span data-label>Test</span>
@@ -120,7 +169,7 @@ export const defaultPDSEnhancerMetadata = [
120
169
  selector: "[data-clip]",
121
170
  description:
122
171
  "Enables click/keyboard toggling for line-clamped content blocks.",
123
- demoHtml: `
172
+ demoHtml: /*html*/`
124
173
  <div data-clip="2" data-clip-more="more...">
125
174
  <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
126
175
  <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
@@ -132,7 +181,7 @@ export const defaultPDSEnhancerMetadata = [
132
181
  selector: "button, a[class*='btn-']",
133
182
  description:
134
183
  "Automatically manages spinner icon for buttons with .btn-working class",
135
- demoHtml: `
184
+ demoHtml: /*html*/`
136
185
  <button class="btn-primary btn-working">
137
186
  <span>Saving</span>
138
187
  </button>
@@ -18,6 +18,7 @@ const enhancerDefinitions = [
18
18
  { selector: "nav[data-dropdown]" },
19
19
  { selector: "label[data-toggle]" },
20
20
  { selector: "label[data-color]" },
21
+ { selector: 'input[autocomplete="one-time-code"]' },
21
22
  { selector: 'input[type="range"]' },
22
23
  { selector: "form[data-required]" },
23
24
  { selector: "fieldset[role=group][data-open]" },
@@ -566,6 +567,197 @@ function enhanceColorInput(elem) {
566
567
  input.addEventListener("change", setResolved, { passive: true });
567
568
  }
568
569
 
570
+ function enhanceOneTimeCodeInput(elem) {
571
+ if (elem.dataset.enhancedOneTimeCode) return;
572
+ elem.dataset.enhancedOneTimeCode = "true";
573
+
574
+ const configuredLength = Number.parseInt(
575
+ elem.getAttribute("data-otp-length") || elem.getAttribute("maxlength") || "6",
576
+ 10,
577
+ );
578
+ const length = Number.isFinite(configuredLength) && configuredLength > 0 ? configuredLength : 6;
579
+ const autoSubmit = elem.getAttribute("data-otp-autosubmit") !== "false";
580
+ const allowAlphanumeric = elem.getAttribute("data-otp-format") === "alphanumeric";
581
+ const statusId =
582
+ elem.getAttribute("data-otp-status-id") ||
583
+ `${elem.id || `otp-${Math.random().toString(36).slice(2, 9)}`}-status`;
584
+
585
+ const normalizeValue = (value) => {
586
+ const compact = String(value || "").replace(/\s+/g, "");
587
+ const filtered = allowAlphanumeric
588
+ ? compact.replace(/[^0-9a-z]/gi, "")
589
+ : compact.replace(/\D+/g, "");
590
+ return filtered.slice(0, length);
591
+ };
592
+
593
+ elem.classList.add("input-otp");
594
+ elem.dataset.otpLength = String(length);
595
+ elem.dataset.otpComplete = "false";
596
+ elem.style.setProperty("--otp-digits", String(length));
597
+ elem.style.setProperty("--_otp-digit", "0");
598
+
599
+ if (!elem.hasAttribute("type") || elem.getAttribute("type")?.toLowerCase() === "number") {
600
+ elem.setAttribute("type", "text");
601
+ }
602
+ elem.setAttribute("maxlength", String(length));
603
+ if (!elem.hasAttribute("inputmode")) {
604
+ elem.setAttribute("inputmode", allowAlphanumeric ? "text" : "numeric");
605
+ }
606
+ if (!elem.hasAttribute("enterkeyhint")) {
607
+ elem.setAttribute("enterkeyhint", "done");
608
+ }
609
+ if (!elem.hasAttribute("autocapitalize")) {
610
+ elem.setAttribute("autocapitalize", "off");
611
+ }
612
+ if (!elem.hasAttribute("spellcheck")) {
613
+ elem.setAttribute("spellcheck", "false");
614
+ }
615
+ if (!elem.hasAttribute("pattern")) {
616
+ elem.setAttribute(
617
+ "pattern",
618
+ allowAlphanumeric ? `[0-9A-Za-z]{${length}}` : `\\d{${length}}`,
619
+ );
620
+ }
621
+ if (!elem.hasAttribute("aria-label") && !elem.labels?.length) {
622
+ elem.setAttribute("aria-label", msg("One-time code"));
623
+ }
624
+
625
+ const form = elem.form || elem.closest("form");
626
+ let autoSubmitPending = false;
627
+ let status = null;
628
+
629
+ const syncActiveDigit = () => {
630
+ const selectionStart =
631
+ typeof elem.selectionStart === "number" ? elem.selectionStart : elem.value.length;
632
+ const clamped = Math.max(0, Math.min(selectionStart, Math.max(length - 1, 0)));
633
+ elem.style.setProperty("--_otp-digit", String(clamped));
634
+ };
635
+
636
+ const syncScrollPosition = () => {
637
+ if (typeof elem.scrollLeft === "number") {
638
+ elem.scrollLeft = 0;
639
+ }
640
+ };
641
+
642
+ if (typeof document !== "undefined") {
643
+ status = document.getElementById(statusId);
644
+ if (!status) {
645
+ status = document.createElement("span");
646
+ status.id = statusId;
647
+ status.className = "otp-status";
648
+ status.setAttribute("aria-live", "polite");
649
+ status.setAttribute("aria-atomic", "true");
650
+ elem.insertAdjacentElement("afterend", status);
651
+ }
652
+
653
+ const describedBy = new Set(
654
+ (elem.getAttribute("aria-describedby") || "")
655
+ .split(/\s+/)
656
+ .filter(Boolean),
657
+ );
658
+ describedBy.add(statusId);
659
+ elem.setAttribute("aria-describedby", Array.from(describedBy).join(" "));
660
+ }
661
+
662
+ const updateStatus = () => {
663
+ if (!status) return;
664
+ const count = elem.value.length;
665
+ status.textContent =
666
+ count === 0
667
+ ? msg("Enter the verification code")
668
+ : count >= length
669
+ ? msg("Code complete")
670
+ : `${count}/${length}`;
671
+ };
672
+
673
+ const attemptSubmit = () => {
674
+ if (!autoSubmit || autoSubmitPending || !form) return;
675
+ if (typeof form.checkValidity === "function" && !form.checkValidity()) return;
676
+ autoSubmitPending = true;
677
+ requestAnimationFrame(() => {
678
+ autoSubmitPending = false;
679
+ const submitSelector = elem.getAttribute("data-otp-submit-selector");
680
+ const submitter = submitSelector ? form.querySelector(submitSelector) : undefined;
681
+ if (typeof form.requestSubmit === "function") {
682
+ form.requestSubmit(submitter || undefined);
683
+ } else {
684
+ form.submit();
685
+ }
686
+ });
687
+ };
688
+
689
+ const syncValue = (nextValue, { dispatchChange = false } = {}) => {
690
+ const normalized = normalizeValue(nextValue);
691
+ if (elem.value !== normalized) {
692
+ elem.value = normalized;
693
+ }
694
+
695
+ const isComplete = normalized.length === length;
696
+ elem.dataset.otpComplete = isComplete ? "true" : "false";
697
+ updateStatus();
698
+ syncActiveDigit();
699
+ syncScrollPosition();
700
+
701
+ if (dispatchChange) {
702
+ elem.dispatchEvent(new Event("change", { bubbles: true }));
703
+ }
704
+
705
+ if (isComplete) {
706
+ attemptSubmit();
707
+ }
708
+ };
709
+
710
+ elem.addEventListener("beforeinput", (event) => {
711
+ if (event.defaultPrevented) return;
712
+ if (event.inputType?.startsWith("delete")) return;
713
+ if (typeof event.data !== "string" || event.data.length === 0) return;
714
+
715
+ const normalized = normalizeValue(event.data);
716
+ if (!normalized && event.data.trim()) {
717
+ event.preventDefault();
718
+ }
719
+ });
720
+
721
+ elem.addEventListener("input", () => {
722
+ syncValue(elem.value);
723
+ try {
724
+ const end = elem.value.length;
725
+ elem.setSelectionRange(end, end);
726
+ } catch {
727
+ // Ignore selection API issues on unsupported input modes
728
+ }
729
+ syncActiveDigit();
730
+ syncScrollPosition();
731
+ });
732
+
733
+ ["focus", "click", "keyup", "select"].forEach((eventName) => {
734
+ elem.addEventListener(eventName, () => {
735
+ requestAnimationFrame(() => {
736
+ syncActiveDigit();
737
+ syncScrollPosition();
738
+ });
739
+ });
740
+ });
741
+
742
+ elem.addEventListener("paste", (event) => {
743
+ const pasted = event.clipboardData?.getData("text") || "";
744
+ if (!pasted) return;
745
+
746
+ event.preventDefault();
747
+ elem.value = normalizeValue(pasted);
748
+ elem.dispatchEvent(new Event("input", { bubbles: true }));
749
+ elem.dispatchEvent(new Event("change", { bubbles: true }));
750
+ });
751
+
752
+ elem.addEventListener("keydown", (event) => {
753
+ if (event.key === "Enter" && elem.value.length === length) {
754
+ attemptSubmit();
755
+ }
756
+ });
757
+
758
+ syncValue(elem.value);
759
+ }
760
+
569
761
  function enhanceRange(elem) {
570
762
  if (elem.dataset.enhancedRange) return;
571
763
 
@@ -891,6 +1083,7 @@ const enhancerRunners = new Map([
891
1083
  ["nav[data-dropdown]", enhanceDropdown],
892
1084
  ["label[data-toggle]", enhanceToggle],
893
1085
  ["label[data-color]", enhanceColorInput],
1086
+ ['input[autocomplete="one-time-code"]', enhanceOneTimeCodeInput],
894
1087
  ['input[type="range"]', enhanceRange],
895
1088
  ["form[data-required]", enhanceRequired],
896
1089
  ["fieldset[role=group][data-open]", enhanceOpenGroup],
@@ -2628,6 +2628,124 @@ input, textarea, select {
2628
2628
  }
2629
2629
  }
2630
2630
 
2631
+ /* One-time-code / OTP input enhancement — single input, segmented visual style */
2632
+ input[autocomplete="one-time-code"],
2633
+ input.input-otp {
2634
+ --otp-digits: 6;
2635
+ --otp-ls: 1.6ch;
2636
+ --otp-gap: 1.15;
2637
+ --otp-edge-pad: max(var(--spacing-2), calc(var(--radius-md) * 0.8));
2638
+ --otp-start-shift: calc((((var(--_otp-bgsz) - 1ch) / 2) * 0.98));
2639
+ --otp-pad-start: calc(var(--otp-edge-pad) + var(--otp-start-shift));
2640
+ --otp-pad-end: var(--otp-edge-pad);
2641
+ --otp-cell-bg: color-mix(in oklab, var(--color-surface-subtle) 94%, var(--color-primary-fill) 6%);
2642
+ --otp-active-bg: color-mix(in oklab, var(--color-primary-fill) 18%, var(--color-surface-base));
2643
+ --_otp-bgsz: calc(var(--otp-ls) + 1ch);
2644
+ --_otp-digit: 0;
2645
+
2646
+ all: unset;
2647
+ display: block;
2648
+ box-sizing: border-box;
2649
+ inline-size: calc((var(--otp-digits) * var(--_otp-bgsz)) + var(--otp-pad-start) + var(--otp-pad-end));
2650
+ max-inline-size: 100%;
2651
+ min-inline-size: 0;
2652
+ min-block-size: auto;
2653
+ padding-block: max(var(--spacing-3), 0.9ch);
2654
+ padding-inline-start: var(--otp-pad-start);
2655
+ padding-inline-end: var(--otp-pad-end);
2656
+ border-radius: max(var(--radius-md), calc(var(--otp-edge-pad) * 0.9));
2657
+ text-align: left;
2658
+ text-indent: 0;
2659
+ letter-spacing: var(--otp-ls);
2660
+ font-family: var(--font-family-mono, monospace);
2661
+ font-variant-numeric: tabular-nums;
2662
+ font-size: clamp(var(--font-size-lg), 2vw, calc(var(--font-size-xl) + var(--font-size-xs)));
2663
+ line-height: 1;
2664
+ white-space: nowrap;
2665
+ direction: ltr;
2666
+ color: var(--color-text-primary);
2667
+ caret-color: var(--color-text-primary);
2668
+ cursor: text;
2669
+ overflow: hidden;
2670
+ scrollbar-width: none;
2671
+ background:
2672
+ linear-gradient(
2673
+ 90deg,
2674
+ var(--otp-active-bg) 0 calc(var(--otp-gap) * var(--otp-ls)),
2675
+ transparent calc(var(--otp-gap) * var(--otp-ls)) 100%
2676
+ ) calc(var(--_otp-digit) * var(--_otp-bgsz)) 0 / var(--_otp-bgsz) 100% no-repeat,
2677
+ repeating-linear-gradient(
2678
+ 90deg,
2679
+ var(--otp-cell-bg) 0 calc(var(--otp-gap) * var(--otp-ls)),
2680
+ transparent calc(var(--otp-gap) * var(--otp-ls)) var(--_otp-bgsz)
2681
+ ) 0 0 / var(--_otp-bgsz) 100% repeat-x,
2682
+ var(--color-input-bg);
2683
+ background-origin: content-box, content-box, border-box;
2684
+ background-clip: content-box, content-box, border-box;
2685
+ box-shadow:
2686
+ inset 0 0 0 var(--border-width-medium) var(--color-border),
2687
+ var(--shadow-sm);
2688
+
2689
+ &::-webkit-scrollbar {
2690
+ display: none;
2691
+ }
2692
+
2693
+ &::placeholder {
2694
+ color: transparent;
2695
+ }
2696
+
2697
+ &:focus {
2698
+ box-shadow:
2699
+ inset 0 0 0 var(--border-width-medium) var(--color-focus-ring, var(--color-primary-500)),
2700
+ 0 0 0 ${focusWidth}px color-mix(in oklab, var(--color-focus-ring, var(--color-primary-500)) ${Math.round(
2701
+ (focusRingOpacity || 0.3) * 100,
2702
+ )}%, transparent);
2703
+ }
2704
+
2705
+ &:invalid {
2706
+ box-shadow:
2707
+ inset 0 0 0 var(--border-width-medium) var(--color-border),
2708
+ var(--shadow-sm);
2709
+ }
2710
+
2711
+ &:invalid:focus {
2712
+ box-shadow:
2713
+ inset 0 0 0 var(--border-width-medium) var(--color-focus-ring, var(--color-primary-500)),
2714
+ 0 0 0 ${focusWidth}px color-mix(in oklab, var(--color-focus-ring, var(--color-primary-500)) ${Math.round(
2715
+ (focusRingOpacity || 0.3) * 100,
2716
+ )}%, transparent);
2717
+ }
2718
+
2719
+ &[data-otp-complete="true"] {
2720
+ --otp-active-bg: color-mix(in oklab, var(--color-success-fill, var(--color-primary-fill)) 18%, var(--color-surface-base));
2721
+ }
2722
+
2723
+ &:disabled {
2724
+ color: var(--color-input-disabled-text);
2725
+ caret-color: transparent;
2726
+ background:
2727
+ linear-gradient(
2728
+ 90deg,
2729
+ color-mix(in oklab, var(--color-input-disabled-bg) 90%, var(--color-border) 10%) calc(var(--otp-gap) * var(--otp-ls)),
2730
+ transparent 0
2731
+ ) 0 0 / var(--_otp-bgsz) 100% repeat-x;
2732
+ box-shadow: inset 0 0 0 var(--border-width-medium) var(--color-border);
2733
+ }
2734
+ }
2735
+
2736
+ .otp-status {
2737
+ position: absolute;
2738
+ width: 1px;
2739
+ height: 1px;
2740
+ padding: 0;
2741
+ margin: -1px;
2742
+ overflow: hidden;
2743
+ clip: rect(0 0 0 0);
2744
+ clip-path: inset(50%);
2745
+ white-space: nowrap;
2746
+ border: 0;
2747
+ }
2748
+
2631
2749
  input[type="range"] {
2632
2750
  padding: 0;
2633
2751
  background: transparent;