@leavepulse/ui 0.53.1 → 0.54.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,131 @@
1
+ import { defineComponent, computed, ref, watch, openBlock, createBlock, unref, mergeProps, withCtx, createElementBlock, Fragment, renderList, createVNode, normalizeClass, createCommentVNode } from "vue";
2
+ import { PinInputRoot, PinInputInput } from "reka-ui";
3
+ import { useFieldControl } from "../composables/useField.js";
4
+ import { useMergedAttrs } from "../composables/useMergedClass.js";
5
+ import { splitCode, upperCells, joinCells, groupBreaks } from "../components/otp.js";
6
+ const _hoisted_1 = {
7
+ key: 0,
8
+ "aria-hidden": "true",
9
+ class: "h-0.5 w-2 shrink-0 rounded-full bg-muted opacity-60"
10
+ };
11
+ const __default__ = { inheritAttrs: false };
12
+ const _sfc_main = /* @__PURE__ */ defineComponent({
13
+ ...__default__,
14
+ __name: "LpOtpInput",
15
+ props: {
16
+ modelValue: {},
17
+ length: { default: 6 },
18
+ disabled: { type: Boolean },
19
+ mask: { type: Boolean },
20
+ alphanumeric: { type: Boolean, default: false },
21
+ uppercase: { type: Boolean, default: void 0 },
22
+ group: {},
23
+ invalid: { type: Boolean },
24
+ size: { default: "md" }
25
+ },
26
+ emits: ["update:modelValue", "complete"],
27
+ setup(__props, { emit: __emit }) {
28
+ const props = __props;
29
+ const emit = __emit;
30
+ const upper = computed(() => props.alphanumeric && (props.uppercase ?? true));
31
+ const local = ref(splitCode(props.modelValue, props.length));
32
+ let emitted = props.modelValue ?? "";
33
+ watch(
34
+ () => props.modelValue ?? "",
35
+ (value) => {
36
+ if (value === emitted) return;
37
+ emitted = value;
38
+ local.value = splitCode(value, props.length);
39
+ }
40
+ );
41
+ watch(
42
+ () => props.length,
43
+ (length) => {
44
+ local.value = local.value.slice(0, length);
45
+ }
46
+ );
47
+ const cells = computed({
48
+ get: () => local.value,
49
+ set: (arr) => {
50
+ local.value = upper.value ? upperCells(arr) : arr;
51
+ emitted = joinCells(local.value);
52
+ emit("update:modelValue", emitted);
53
+ }
54
+ });
55
+ const field = useFieldControl(() => ({
56
+ wrapped: false,
57
+ invalid: props.invalid,
58
+ disabled: props.disabled
59
+ }));
60
+ function onComplete(arr) {
61
+ emit("complete", joinCells(upper.value ? upperCells(arr) : arr));
62
+ }
63
+ const breaks = computed(() => groupBreaks(props.length, props.group));
64
+ const root = ref(null);
65
+ function onBeforeInput(event, index) {
66
+ if (event.inputType !== "deleteContentBackward") return;
67
+ event.preventDefault();
68
+ const target = event.target;
69
+ const next = [...local.value];
70
+ let clear = index;
71
+ if (!target.value && index > 0) {
72
+ clear = index - 1;
73
+ root.value?.$el.querySelectorAll("input")[clear]?.focus();
74
+ }
75
+ next[clear] = props.alphanumeric ? "" : void 0;
76
+ cells.value = next;
77
+ }
78
+ const cellSize = {
79
+ sm: "max-w-9 text-base",
80
+ md: "max-w-11 text-lg",
81
+ lg: "max-w-13 text-xl"
82
+ };
83
+ const { class: rootClass, attrs: rest } = useMergedAttrs(
84
+ "flex min-w-0 max-w-full items-center gap-1.5 sm:gap-2"
85
+ );
86
+ return (_ctx, _cache) => {
87
+ return openBlock(), createBlock(unref(PinInputRoot), mergeProps({
88
+ ref_key: "root",
89
+ ref: root,
90
+ modelValue: cells.value,
91
+ "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => cells.value = $event),
92
+ type: __props.alphanumeric ? "text" : "number",
93
+ mask: __props.mask,
94
+ disabled: unref(field).disabled,
95
+ otp: "",
96
+ class: unref(rootClass)
97
+ }, unref(rest), { onComplete }), {
98
+ default: withCtx(() => [
99
+ (openBlock(true), createElementBlock(Fragment, null, renderList(__props.length, (i) => {
100
+ return openBlock(), createElementBlock(Fragment, { key: i }, [
101
+ createVNode(unref(PinInputInput), {
102
+ index: i - 1,
103
+ id: i === 1 ? unref(field).id : void 0,
104
+ "aria-invalid": unref(field).invalid || void 0,
105
+ "aria-describedby": unref(field).describedBy,
106
+ autocapitalize: upper.value ? "characters" : "none",
107
+ autocorrect: "off",
108
+ spellcheck: false,
109
+ class: normalizeClass([
110
+ cellSize[__props.size],
111
+ "aspect-square h-auto min-w-0 flex-1 basis-0 p-0",
112
+ "rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand",
113
+ "outline-none transition-colors duration-[var(--duration-fast)]",
114
+ "focus:border-brand focus:ring-2 focus:ring-ring",
115
+ "disabled:cursor-not-allowed disabled:opacity-55",
116
+ unref(field).invalid ? "border-danger focus:border-danger focus:ring-danger-soft" : "border-line"
117
+ ]),
118
+ onBeforeinput: ($event) => onBeforeInput($event, i - 1)
119
+ }, null, 8, ["index", "id", "aria-invalid", "aria-describedby", "autocapitalize", "class", "onBeforeinput"]),
120
+ breaks.value.has(i - 1) ? (openBlock(), createElementBlock("span", _hoisted_1)) : createCommentVNode("", true)
121
+ ], 64);
122
+ }), 128))
123
+ ]),
124
+ _: 1
125
+ }, 16, ["modelValue", "type", "mask", "disabled", "class"]);
126
+ };
127
+ }
128
+ });
129
+ export {
130
+ _sfc_main as _
131
+ };
@@ -1,9 +1,22 @@
1
- import { defineComponent, ref, watch, nextTick, useId, computed, openBlock, createBlock, unref, withCtx, createVNode, normalizeClass, createElementBlock, Fragment, renderList, createCommentVNode, createElementVNode, toDisplayString, renderSlot } from "vue";
1
+ import { defineComponent, computed, ref, watch, nextTick, useId, openBlock, createBlock, unref, withCtx, createVNode, normalizeClass, createElementBlock, Fragment, renderList, createCommentVNode, createElementVNode, toDisplayString, renderSlot } from "vue";
2
2
  import { useReducedMotion, Motion } from "motion-v";
3
3
  import { TabsRoot, TabsList, TabsTrigger, TabsContent } from "reka-ui";
4
4
  import { usePillTransition } from "../composables/usePillTransition.js";
5
5
  import { _ as _sfc_main$1 } from "./LpIcon-DYtaid0K.js";
6
- const _hoisted_1 = { class: "relative z-10" };
6
+ const _hoisted_1 = {
7
+ key: 1,
8
+ class: "relative z-10 inline-flex"
9
+ };
10
+ const _hoisted_2 = {
11
+ key: 0,
12
+ "aria-hidden": "true",
13
+ class: "absolute -right-1 -top-0.5 size-2 rounded-full bg-accent"
14
+ };
15
+ const _hoisted_3 = {
16
+ key: 2,
17
+ "aria-hidden": "true",
18
+ class: "relative z-10 size-1.5 self-start rounded-full bg-accent"
19
+ };
7
20
  const _sfc_main = /* @__PURE__ */ defineComponent({
8
21
  __name: "LpTabs",
9
22
  props: {
@@ -12,11 +25,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
12
25
  items: {},
13
26
  variant: { default: "contained" },
14
27
  accent: { type: Boolean, default: false },
15
- block: { type: Boolean, default: false }
28
+ block: { type: Boolean, default: false },
29
+ label: {}
16
30
  },
17
31
  emits: ["update:modelValue"],
18
32
  setup(__props, { emit: __emit }) {
19
33
  const props = __props;
34
+ const bottom = computed(() => props.variant === "bottom");
20
35
  const emit = __emit;
21
36
  const current = ref(props.modelValue ?? props.defaultValue ?? props.items[0]?.value);
22
37
  watch(
@@ -50,6 +65,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
50
65
  () => reduceMotion.value ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 42, mass: 0.9 }
51
66
  );
52
67
  function pillUnder(value, active) {
68
+ if (bottom.value) return active === value;
53
69
  return hovered.value ? hovered.value === value : active === value;
54
70
  }
55
71
  function releaseFocusAfterPointer(event) {
@@ -65,11 +81,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
65
81
  createVNode(unref(TabsList), {
66
82
  ref_key: "list",
67
83
  ref: list,
68
- class: normalizeClass(["max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", [
84
+ "aria-label": __props.label,
85
+ class: normalizeClass(bottom.value ? "grid w-full auto-cols-fr grid-flow-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]" : [
86
+ "max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
69
87
  __props.block ? "flex w-full" : "inline-flex",
70
88
  __props.variant === "contained" ? "rounded-control border border-line bg-surface-soft p-1" : "",
71
89
  __props.variant === "underline" ? "border-b border-line" : ""
72
- ]]),
90
+ ]),
73
91
  onPointerleave: _cache[0] || (_cache[0] = ($event) => hovered.value = null)
74
92
  }, {
75
93
  default: withCtx(() => [
@@ -77,11 +95,13 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
77
95
  return openBlock(), createBlock(unref(TabsTrigger), {
78
96
  key: item.value,
79
97
  value: item.value,
80
- class: normalizeClass(["relative inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring", [
81
- __props.block ? "flex-1" : "",
82
- __props.variant === "underline" ? "rounded-t-md px-3 pt-1.5 pb-2" : "rounded-md px-3 py-1.5",
98
+ class: normalizeClass(["relative inline-flex items-center justify-center outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring", [
99
+ bottom.value ? "min-h-14 min-w-0 flex-col gap-0.5 px-1 py-1.5 text-[11px] font-medium focus-visible:ring-inset" : "shrink-0 gap-1.5 whitespace-nowrap text-sm",
100
+ __props.block && !bottom.value ? "flex-1" : "",
101
+ __props.variant === "underline" ? "rounded-t-md px-3 pt-1.5 pb-2" : "",
102
+ __props.variant === "contained" || __props.variant === "plain" ? "rounded-md px-3 py-1.5" : "",
83
103
  __props.accent ? "data-[state=active]:text-brand" : "data-[state=active]:text-ink",
84
- hovered.value === item.value ? "text-ink" : "text-muted"
104
+ hovered.value === item.value && !bottom.value ? "text-ink" : "text-muted"
85
105
  ]]),
86
106
  onPointerenter: ($event) => hovered.value = item.value,
87
107
  onPointerup: releaseFocusAfterPointer
@@ -94,25 +114,32 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
94
114
  class: normalizeClass(__props.variant === "underline" ? [
95
115
  "absolute inset-x-0 -bottom-px z-0 h-0.5 rounded-full",
96
116
  __props.accent ? "bg-brand" : "bg-ink"
117
+ ] : bottom.value ? [
118
+ "absolute inset-x-[22%] top-0 z-0 h-0.5 rounded-b-full",
119
+ __props.accent ? "bg-brand" : "bg-ink"
97
120
  ] : [
98
121
  "absolute inset-0 z-0 rounded-md shadow-sm",
99
122
  __props.accent ? "border border-brand/35 bg-brand/12" : "border border-line bg-surface-raised"
100
123
  ])
101
124
  }, null, 8, ["transition", "class"])) : createCommentVNode("", true),
102
- item.icon ? (openBlock(), createBlock(_sfc_main$1, {
103
- key: 1,
104
- name: item.icon,
105
- size: 14,
106
- class: "relative z-10"
107
- }, null, 8, ["name"])) : createCommentVNode("", true),
108
- createElementVNode("span", _hoisted_1, toDisplayString(item.label), 1)
125
+ item.icon ? (openBlock(), createElementBlock("span", _hoisted_1, [
126
+ createVNode(_sfc_main$1, {
127
+ name: item.icon,
128
+ size: bottom.value ? 22 : 14
129
+ }, null, 8, ["name", "size"]),
130
+ item.dot ? (openBlock(), createElementBlock("span", _hoisted_2)) : createCommentVNode("", true)
131
+ ])) : createCommentVNode("", true),
132
+ createElementVNode("span", {
133
+ class: normalizeClass(["relative z-10", bottom.value ? "max-w-full truncate" : ""])
134
+ }, toDisplayString(item.label), 3),
135
+ item.dot && !item.icon ? (openBlock(), createElementBlock("span", _hoisted_3)) : createCommentVNode("", true)
109
136
  ]),
110
137
  _: 2
111
138
  }, 1032, ["value", "class", "onPointerenter"]);
112
139
  }), 128))
113
140
  ]),
114
141
  _: 1
115
- }, 8, ["class"]),
142
+ }, 8, ["aria-label", "class"]),
116
143
  _ctx.$slots.panel ? (openBlock(), createBlock(unref(Motion), {
117
144
  key: 0,
118
145
  layout: true,
@@ -10,6 +10,14 @@ declare const __VLS_export: import("vue").DefineComponent<{
10
10
  mask?: boolean;
11
11
  /** Allow letters too (backup codes). Default: digits only (TOTP). */
12
12
  alphanumeric?: boolean;
13
+ /**
14
+ * Turn typed letters into capitals, and open a phone's keyboard in capitals.
15
+ * Default: on with `alphanumeric`, where codes are printed in capitals and
16
+ * compared without regard to case; pass `false` for a case-sensitive code.
17
+ */
18
+ uppercase?: boolean;
19
+ /** Draw a separator after every `group` cells (e.g. 3 for "123-456"). */
20
+ group?: number;
13
21
  /** Error state — tints the cells red (e.g. wrong code). */
14
22
  invalid?: boolean;
15
23
  size?: "sm" | "md" | "lg";
@@ -26,6 +34,14 @@ declare const __VLS_export: import("vue").DefineComponent<{
26
34
  mask?: boolean;
27
35
  /** Allow letters too (backup codes). Default: digits only (TOTP). */
28
36
  alphanumeric?: boolean;
37
+ /**
38
+ * Turn typed letters into capitals, and open a phone's keyboard in capitals.
39
+ * Default: on with `alphanumeric`, where codes are printed in capitals and
40
+ * compared without regard to case; pass `false` for a case-sensitive code.
41
+ */
42
+ uppercase?: boolean;
43
+ /** Draw a separator after every `group` cells (e.g. 3 for "123-456"). */
44
+ group?: number;
29
45
  /** Error state — tints the cells red (e.g. wrong code). */
30
46
  invalid?: boolean;
31
47
  size?: "sm" | "md" | "lg";
@@ -35,5 +51,6 @@ declare const __VLS_export: import("vue").DefineComponent<{
35
51
  }>, {
36
52
  size: "sm" | "md" | "lg";
37
53
  length: number;
54
+ uppercase: boolean;
38
55
  alphanumeric: boolean;
39
56
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpOtpInput-CziPF3Oq.js";
1
+ import { _ as _sfc_main } from "../chunks/LpOtpInput-PbeNJ5In.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -3,6 +3,8 @@ export interface TabItem {
3
3
  label: string;
4
4
  /** Optional iconify name (e.g. "lucide:palette"), shown left of the label. */
5
5
  icon?: string;
6
+ /** A small dot on the tab: something behind it wants a look (unread, a problem). */
7
+ dot?: boolean;
6
8
  }
7
9
  type __VLS_Props = {
8
10
  modelValue?: string;
@@ -15,12 +17,18 @@ type __VLS_Props = {
15
17
  * sliding pill marking the active one (for nav bars over a custom surface).
16
18
  * "underline" is a flat row over a hairline baseline, marking the active
17
19
  * tab with a sliding bottom bar instead of a pill (page-section nav style).
20
+ * "bottom" is a phone's bottom navigation: full width, equal-share tabs
21
+ * with the icon over the label, a sliding bar along the top edge marking
22
+ * the active one, and padding for the gesture strip and side insets of an
23
+ * edge-to-edge screen. Place it as the last row of the layout.
18
24
  */
19
- variant?: "contained" | "plain" | "underline";
25
+ variant?: "contained" | "plain" | "underline" | "bottom";
20
26
  /** Tint the active pill + label with the brand colour (nav-bar style). */
21
27
  accent?: boolean;
22
28
  /** Stretch the bar to full width with equal-share triggers. */
23
29
  block?: boolean;
30
+ /** Accessible name of the tab list, e.g. "Main" for an app's navigation. */
31
+ label?: string;
24
32
  };
25
33
  declare var __VLS_51: {
26
34
  value: string;
@@ -36,7 +44,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {},
36
44
  "onUpdate:modelValue"?: ((value: string) => any) | undefined;
37
45
  }>, {
38
46
  block: boolean;
39
- variant: "contained" | "plain" | "underline";
47
+ variant: "contained" | "plain" | "underline" | "bottom";
40
48
  accent: boolean;
41
49
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
42
50
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpTabs-UM0FQmPA.js";
1
+ import { _ as _sfc_main } from "../chunks/LpTabs-BMSBB78h.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -3,3 +3,5 @@ export type OtpCells = (string | number | undefined)[];
3
3
  export declare function joinCells(cells: OtpCells): string;
4
4
  /** Cells for a code given from outside, one character each. */
5
5
  export declare function splitCode(code: string | undefined, length: number): string[];
6
+ export declare function upperCells<T extends OtpCells>(cells: T): T;
7
+ export declare function groupBreaks(length: number, group: number | undefined): Set<number>;
@@ -4,7 +4,23 @@ function joinCells(cells) {
4
4
  function splitCode(code, length) {
5
5
  return (code ?? "").split("").slice(0, length);
6
6
  }
7
+ function upperCells(cells) {
8
+ const out = [];
9
+ cells.forEach((c, i) => {
10
+ out[i] = typeof c === "string" ? c.toUpperCase() : c;
11
+ });
12
+ out.length = cells.length;
13
+ return out;
14
+ }
15
+ function groupBreaks(length, group) {
16
+ const breaks = /* @__PURE__ */ new Set();
17
+ if (!group || group < 1 || group >= length) return breaks;
18
+ for (let i = group; i < length; i += group) breaks.add(i - 1);
19
+ return breaks;
20
+ }
7
21
  export {
22
+ groupBreaks,
8
23
  joinCells,
9
- splitCode
24
+ splitCode,
25
+ upperCells
10
26
  };
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ import { _ as _41 } from "./chunks/LpNotificationBell-wZ3meeim.js";
65
65
  import { _ as _42 } from "./chunks/LpNumberField-B77O-z28.js";
66
66
  import { _ as _43 } from "./chunks/LpTimeField-Cqe7fXnh.js";
67
67
  import { _ as _44 } from "./chunks/LpNumberFlow-z4WPZcZu.js";
68
- import { _ as _45 } from "./chunks/LpOtpInput-CziPF3Oq.js";
68
+ import { _ as _45 } from "./chunks/LpOtpInput-PbeNJ5In.js";
69
69
  import { _ as _46 } from "./chunks/LpPagination-BcDlo3r_.js";
70
70
  import { _ as _47 } from "./chunks/LpPasswordInput-eKzQhbQP.js";
71
71
  import { _ as _48 } from "./chunks/LpPie-B7ti5qCH.js";
@@ -96,7 +96,7 @@ import { _ as _69 } from "./chunks/LpSwitch-DG2AhXIL.js";
96
96
  import { default as default12 } from "./components/LpTable.vue.js";
97
97
  import { compareValues } from "./components/tableSort.js";
98
98
  import { _ as _70 } from "./chunks/LpTableOfContents-BUcAfGPQ.js";
99
- import { _ as _71 } from "./chunks/LpTabs-UM0FQmPA.js";
99
+ import { _ as _71 } from "./chunks/LpTabs-BMSBB78h.js";
100
100
  import { _ as _72 } from "./chunks/LpTagsInput-D1SuqbhB.js";
101
101
  import { addTags, splitTags } from "./components/tags.js";
102
102
  import { _ as _73 } from "./chunks/LpTextarea-uuScWo6-.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -14,12 +14,16 @@ export default { inheritAttrs: false }
14
14
  * The kit-facing model is a single STRING (the joined code), not reka's per-cell
15
15
  * array: `v-model` is "123456", and @complete fires that string once every cell
16
16
  * is filled. Digits-only by default (TOTP); flip `alphanumeric` for backup codes.
17
+ *
18
+ * The cells share the row rather than keeping a fixed size: each grows to its
19
+ * size's width and no further, and shrinks with the row when the container is
20
+ * narrower, so eight cells fit a phone instead of running off its edge.
17
21
  */
18
22
  import { PinInputInput, PinInputRoot } from "reka-ui"
19
23
  import { computed, ref, watch } from "vue"
20
24
  import { useFieldControl } from "../composables/useField"
21
25
  import { useMergedAttrs } from "../composables/useMergedClass"
22
- import { joinCells, splitCode, type OtpCells } from "./otp"
26
+ import { groupBreaks, joinCells, splitCode, upperCells, type OtpCells } from "./otp"
23
27
 
24
28
  const props = withDefaults(
25
29
  defineProps<{
@@ -32,11 +36,19 @@ const props = withDefaults(
32
36
  mask?: boolean
33
37
  /** Allow letters too (backup codes). Default: digits only (TOTP). */
34
38
  alphanumeric?: boolean
39
+ /**
40
+ * Turn typed letters into capitals, and open a phone's keyboard in capitals.
41
+ * Default: on with `alphanumeric`, where codes are printed in capitals and
42
+ * compared without regard to case; pass `false` for a case-sensitive code.
43
+ */
44
+ uppercase?: boolean
45
+ /** Draw a separator after every `group` cells (e.g. 3 for "123-456"). */
46
+ group?: number
35
47
  /** Error state — tints the cells red (e.g. wrong code). */
36
48
  invalid?: boolean
37
49
  size?: "sm" | "md" | "lg"
38
50
  }>(),
39
- { length: 6, size: "md", alphanumeric: false },
51
+ { length: 6, size: "md", alphanumeric: false, uppercase: undefined },
40
52
  )
41
53
 
42
54
  const emit = defineEmits<{
@@ -45,6 +57,8 @@ const emit = defineEmits<{
45
57
  (e: "complete", value: string): void
46
58
  }>()
47
59
 
60
+ const upper = computed(() => props.alphanumeric && (props.uppercase ?? true))
61
+
48
62
  // reka models the value as an array of per-cell strings; we bridge to a single
49
63
  // string at the boundary so callers never touch the array shape.
50
64
  //
@@ -52,7 +66,7 @@ const emit = defineEmits<{
52
66
  // string on every render: clearing a middle cell leaves a gap the string cannot
53
67
  // express, and re-splitting it shifted every later digit one cell left. The
54
68
  // cells are only rebuilt when the model changes from outside.
55
- const local = ref<string[]>(splitCode(props.modelValue, props.length))
69
+ const local = ref<OtpCells>(splitCode(props.modelValue, props.length))
56
70
  let emitted = props.modelValue ?? ""
57
71
  watch(
58
72
  () => props.modelValue ?? "",
@@ -68,11 +82,13 @@ watch(
68
82
  local.value = local.value.slice(0, length)
69
83
  },
70
84
  )
85
+ // Typed as reka's text-mode array; numeric mode holds numbers and holes, which
86
+ // the helpers in ./otp are written for.
71
87
  const cells = computed<string[]>({
72
- get: () => local.value,
88
+ get: () => local.value as string[],
73
89
  set: (arr) => {
74
- local.value = arr
75
- emitted = joinCells(arr)
90
+ local.value = upper.value ? upperCells(arr) : arr
91
+ emitted = joinCells(local.value)
76
92
  emit("update:modelValue", emitted)
77
93
  },
78
94
  })
@@ -84,23 +100,53 @@ const field = useFieldControl(() => ({
84
100
  }))
85
101
 
86
102
  function onComplete(arr: OtpCells) {
87
- emit("complete", joinCells(arr))
103
+ emit("complete", joinCells(upper.value ? upperCells(arr) : arr))
104
+ }
105
+
106
+ const breaks = computed(() => groupBreaks(props.length, props.group))
107
+
108
+ /*
109
+ * Backspace, as a phone's keyboard sends it.
110
+ *
111
+ * reka handles Backspace on keydown, but an on-screen keyboard reports most
112
+ * keys as "Unidentified", so its handler never ran there: the browser deleted
113
+ * the character itself, and reka read the now-empty cell as typed input and
114
+ * moved the caret FORWARD — and an empty cell could not be backed out of at
115
+ * all. `beforeinput` names the deletion on every keyboard, so it is done here
116
+ * the way a hardware Backspace does it: clear this cell, or step back into
117
+ * the previous one and clear that. A hardware key never reaches this, since
118
+ * reka's keydown handler cancels it first.
119
+ */
120
+ const root = ref<{ $el: HTMLElement } | null>(null)
121
+ function onBeforeInput(event: InputEvent, index: number) {
122
+ if (event.inputType !== "deleteContentBackward") return
123
+ event.preventDefault()
124
+ const target = event.target as HTMLInputElement
125
+ const next = [...local.value]
126
+ let clear = index
127
+ if (!target.value && index > 0) {
128
+ clear = index - 1
129
+ root.value?.$el.querySelectorAll("input")[clear]?.focus()
130
+ }
131
+ // Emptied the way reka empties a cell itself: numeric mode leaves a hole.
132
+ next[clear] = props.alphanumeric ? "" : undefined
133
+ cells.value = next as string[]
88
134
  }
89
135
 
90
136
  const cellSize = {
91
- sm: "size-9 text-base",
92
- md: "size-11 text-lg",
93
- lg: "size-13 text-xl",
137
+ sm: "max-w-9 text-base",
138
+ md: "max-w-11 text-lg",
139
+ lg: "max-w-13 text-xl",
94
140
  }
95
141
 
96
- // The boxes sit on a fixed gap that a narrow form has to tighten.
97
142
  const { class: rootClass, attrs: rest } = useMergedAttrs(
98
- "flex items-center gap-2",
143
+ "flex min-w-0 max-w-full items-center gap-1.5 sm:gap-2",
99
144
  )
100
145
  </script>
101
146
 
102
147
  <template>
103
148
  <PinInputRoot
149
+ ref="root"
104
150
  v-model="cells"
105
151
  :type="alphanumeric ? 'text' : 'number'"
106
152
  :mask="mask"
@@ -110,21 +156,31 @@ const { class: rootClass, attrs: rest } = useMergedAttrs(
110
156
  v-bind="rest"
111
157
  @complete="onComplete"
112
158
  >
113
- <PinInputInput
114
- v-for="i in length"
115
- :key="i"
116
- :index="i - 1"
117
- :id="i === 1 ? field.id : undefined"
118
- :aria-invalid="field.invalid || undefined"
119
- :aria-describedby="field.describedBy"
120
- :class="[
121
- cellSize[size],
122
- 'rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand',
123
- 'outline-none transition-colors duration-[var(--duration-fast)]',
124
- 'focus:border-brand focus:ring-2 focus:ring-ring',
125
- 'disabled:cursor-not-allowed disabled:opacity-55',
126
- field.invalid ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line',
127
- ]"
128
- />
159
+ <template v-for="i in length" :key="i">
160
+ <PinInputInput
161
+ :index="i - 1"
162
+ :id="i === 1 ? field.id : undefined"
163
+ :aria-invalid="field.invalid || undefined"
164
+ :aria-describedby="field.describedBy"
165
+ :autocapitalize="upper ? 'characters' : 'none'"
166
+ autocorrect="off"
167
+ :spellcheck="false"
168
+ :class="[
169
+ cellSize[size],
170
+ 'aspect-square h-auto min-w-0 flex-1 basis-0 p-0',
171
+ 'rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand',
172
+ 'outline-none transition-colors duration-[var(--duration-fast)]',
173
+ 'focus:border-brand focus:ring-2 focus:ring-ring',
174
+ 'disabled:cursor-not-allowed disabled:opacity-55',
175
+ field.invalid ? 'border-danger focus:border-danger focus:ring-danger-soft' : 'border-line',
176
+ ]"
177
+ @beforeinput="onBeforeInput($event as InputEvent, i - 1)"
178
+ />
179
+ <span
180
+ v-if="breaks.has(i - 1)"
181
+ aria-hidden="true"
182
+ class="h-0.5 w-2 shrink-0 rounded-full bg-muted opacity-60"
183
+ />
184
+ </template>
129
185
  </PinInputRoot>
130
186
  </template>
@@ -10,6 +10,8 @@ export interface TabItem {
10
10
  label: string
11
11
  /** Optional iconify name (e.g. "lucide:palette"), shown left of the label. */
12
12
  icon?: string
13
+ /** A small dot on the tab: something behind it wants a look (unread, a problem). */
14
+ dot?: boolean
13
15
  }
14
16
 
15
17
  const props = withDefaults(
@@ -24,15 +26,23 @@ const props = withDefaults(
24
26
  * sliding pill marking the active one (for nav bars over a custom surface).
25
27
  * "underline" is a flat row over a hairline baseline, marking the active
26
28
  * tab with a sliding bottom bar instead of a pill (page-section nav style).
29
+ * "bottom" is a phone's bottom navigation: full width, equal-share tabs
30
+ * with the icon over the label, a sliding bar along the top edge marking
31
+ * the active one, and padding for the gesture strip and side insets of an
32
+ * edge-to-edge screen. Place it as the last row of the layout.
27
33
  */
28
- variant?: "contained" | "plain" | "underline"
34
+ variant?: "contained" | "plain" | "underline" | "bottom"
29
35
  /** Tint the active pill + label with the brand colour (nav-bar style). */
30
36
  accent?: boolean
31
37
  /** Stretch the bar to full width with equal-share triggers. */
32
38
  block?: boolean
39
+ /** Accessible name of the tab list, e.g. "Main" for an app's navigation. */
40
+ label?: string
33
41
  }>(),
34
42
  { variant: "contained", accent: false, block: false },
35
43
  )
44
+
45
+ const bottom = computed(() => props.variant === "bottom")
36
46
  const emit = defineEmits<{ (e: "update:modelValue", value: string): void }>()
37
47
 
38
48
  // Held here rather than left to reka, so the active marker follows the active
@@ -88,7 +98,11 @@ const panelLayoutTransition = computed(() =>
88
98
  : { type: "spring" as const, stiffness: 520, damping: 42, mass: 0.9 },
89
99
  )
90
100
 
101
+ // A bottom bar marks only the active tab: on a phone a tap is the hover, and
102
+ // a bar that chased the mouse across a desktop window would point at a tab
103
+ // nobody chose.
91
104
  function pillUnder(value: string, active?: string): boolean {
105
+ if (bottom.value) return active === value
92
106
  return hovered.value ? hovered.value === value : active === value
93
107
  }
94
108
 
@@ -119,30 +133,38 @@ function releaseFocusAfterPointer(event: PointerEvent) {
119
133
  >
120
134
  <TabsList
121
135
  ref="list"
122
- class="max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
123
- :class="[
124
- block ? 'flex w-full' : 'inline-flex',
125
- variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
126
- variant === 'underline' ? 'border-b border-line' : '',
127
- ]"
136
+ :aria-label="label"
137
+ :class="bottom
138
+ ? 'grid w-full auto-cols-fr grid-flow-col border-t border-line bg-surface pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]'
139
+ : [
140
+ 'max-w-full gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
141
+ block ? 'flex w-full' : 'inline-flex',
142
+ variant === 'contained' ? 'rounded-control border border-line bg-surface-soft p-1' : '',
143
+ variant === 'underline' ? 'border-b border-line' : '',
144
+ ]"
128
145
  @pointerleave="hovered = null"
129
146
  >
130
147
  <TabsTrigger
131
148
  v-for="item in items"
132
149
  :key="item.value"
133
150
  :value="item.value"
134
- class="relative inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
151
+ class="relative inline-flex items-center justify-center outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring"
135
152
  :class="[
136
- block ? 'flex-1' : '',
137
- variant === 'underline' ? 'rounded-t-md px-3 pt-1.5 pb-2' : 'rounded-md px-3 py-1.5',
153
+ bottom
154
+ ? 'min-h-14 min-w-0 flex-col gap-0.5 px-1 py-1.5 text-[11px] font-medium focus-visible:ring-inset'
155
+ : 'shrink-0 gap-1.5 whitespace-nowrap text-sm',
156
+ block && !bottom ? 'flex-1' : '',
157
+ variant === 'underline' ? 'rounded-t-md px-3 pt-1.5 pb-2' : '',
158
+ variant === 'contained' || variant === 'plain' ? 'rounded-md px-3 py-1.5' : '',
138
159
  accent ? 'data-[state=active]:text-brand' : 'data-[state=active]:text-ink',
139
- hovered === item.value ? 'text-ink' : 'text-muted',
160
+ hovered === item.value && !bottom ? 'text-ink' : 'text-muted',
140
161
  ]"
141
162
  @pointerenter="hovered = item.value"
142
163
  @pointerup="releaseFocusAfterPointer"
143
164
  >
144
- <!-- Active marker: a bottom bar in "underline", a filled pill otherwise.
145
- Both slide between tabs via the shared motion layoutId. -->
165
+ <!-- Active marker: a bar under the tab in "underline", over it in
166
+ "bottom", a filled pill otherwise. All slide between tabs via the
167
+ shared motion layoutId. -->
146
168
  <Motion
147
169
  v-if="pillUnder(item.value, current)"
148
170
  :layout-id="pillId"
@@ -152,15 +174,32 @@ function releaseFocusAfterPointer(event: PointerEvent) {
152
174
  'absolute inset-x-0 -bottom-px z-0 h-0.5 rounded-full',
153
175
  accent ? 'bg-brand' : 'bg-ink',
154
176
  ]
155
- : [
156
- 'absolute inset-0 z-0 rounded-md shadow-sm',
157
- accent
158
- ? 'border border-brand/35 bg-brand/12'
159
- : 'border border-line bg-surface-raised',
160
- ]"
177
+ : bottom
178
+ ? [
179
+ 'absolute inset-x-[22%] top-0 z-0 h-0.5 rounded-b-full',
180
+ accent ? 'bg-brand' : 'bg-ink',
181
+ ]
182
+ : [
183
+ 'absolute inset-0 z-0 rounded-md shadow-sm',
184
+ accent
185
+ ? 'border border-brand/35 bg-brand/12'
186
+ : 'border border-line bg-surface-raised',
187
+ ]"
188
+ />
189
+ <span v-if="item.icon" class="relative z-10 inline-flex">
190
+ <LpIcon :name="item.icon" :size="bottom ? 22 : 14" />
191
+ <span
192
+ v-if="item.dot"
193
+ aria-hidden="true"
194
+ class="absolute -right-1 -top-0.5 size-2 rounded-full bg-accent"
195
+ />
196
+ </span>
197
+ <span class="relative z-10" :class="bottom ? 'max-w-full truncate' : ''">{{ item.label }}</span>
198
+ <span
199
+ v-if="item.dot && !item.icon"
200
+ aria-hidden="true"
201
+ class="relative z-10 size-1.5 self-start rounded-full bg-accent"
161
202
  />
162
- <LpIcon v-if="item.icon" :name="item.icon" :size="14" class="relative z-10" />
163
- <span class="relative z-10">{{ item.label }}</span>
164
203
  </TabsTrigger>
165
204
  </TabsList>
166
205
 
@@ -14,3 +14,32 @@ export function joinCells(cells: OtpCells): string {
14
14
  export function splitCode(code: string | undefined, length: number): string[] {
15
15
  return (code ?? "").split("").slice(0, length)
16
16
  }
17
+
18
+ /*
19
+ * Upper-case every filled cell, keeping the holes where they are.
20
+ *
21
+ * A phone opens its keyboard in lower case while backup and pairing codes are
22
+ * printed in capitals, so a code typed letter by letter came out "ab12cd"
23
+ * against an "AB12CD" on screen: a difference the reader cannot fix by looking,
24
+ * and one a server comparing exactly rejects.
25
+ */
26
+ export function upperCells<T extends OtpCells>(cells: T): T {
27
+ const out = [] as unknown as T
28
+ cells.forEach((c, i) => {
29
+ out[i] = typeof c === "string" ? c.toUpperCase() : c
30
+ })
31
+ out.length = cells.length
32
+ return out
33
+ }
34
+
35
+ /*
36
+ * The cell indexes a group separator follows: after every `group` cells, never
37
+ * after the last. Eight characters are read and copied as two blocks of four,
38
+ * which is how such codes are printed, far more easily than as one row.
39
+ */
40
+ export function groupBreaks(length: number, group: number | undefined): Set<number> {
41
+ const breaks = new Set<number>()
42
+ if (!group || group < 1 || group >= length) return breaks
43
+ for (let i = group; i < length; i += group) breaks.add(i - 1)
44
+ return breaks
45
+ }
@@ -1,99 +0,0 @@
1
- import { defineComponent, ref, watch, computed, openBlock, createBlock, unref, mergeProps, withCtx, createElementBlock, Fragment, renderList, normalizeClass } from "vue";
2
- import { PinInputRoot, PinInputInput } from "reka-ui";
3
- import { useFieldControl } from "../composables/useField.js";
4
- import { useMergedAttrs } from "../composables/useMergedClass.js";
5
- import { splitCode, joinCells } from "../components/otp.js";
6
- const __default__ = { inheritAttrs: false };
7
- const _sfc_main = /* @__PURE__ */ defineComponent({
8
- ...__default__,
9
- __name: "LpOtpInput",
10
- props: {
11
- modelValue: {},
12
- length: { default: 6 },
13
- disabled: { type: Boolean },
14
- mask: { type: Boolean },
15
- alphanumeric: { type: Boolean, default: false },
16
- invalid: { type: Boolean },
17
- size: { default: "md" }
18
- },
19
- emits: ["update:modelValue", "complete"],
20
- setup(__props, { emit: __emit }) {
21
- const props = __props;
22
- const emit = __emit;
23
- const local = ref(splitCode(props.modelValue, props.length));
24
- let emitted = props.modelValue ?? "";
25
- watch(
26
- () => props.modelValue ?? "",
27
- (value) => {
28
- if (value === emitted) return;
29
- emitted = value;
30
- local.value = splitCode(value, props.length);
31
- }
32
- );
33
- watch(
34
- () => props.length,
35
- (length) => {
36
- local.value = local.value.slice(0, length);
37
- }
38
- );
39
- const cells = computed({
40
- get: () => local.value,
41
- set: (arr) => {
42
- local.value = arr;
43
- emitted = joinCells(arr);
44
- emit("update:modelValue", emitted);
45
- }
46
- });
47
- const field = useFieldControl(() => ({
48
- wrapped: false,
49
- invalid: props.invalid,
50
- disabled: props.disabled
51
- }));
52
- function onComplete(arr) {
53
- emit("complete", joinCells(arr));
54
- }
55
- const cellSize = {
56
- sm: "size-9 text-base",
57
- md: "size-11 text-lg",
58
- lg: "size-13 text-xl"
59
- };
60
- const { class: rootClass, attrs: rest } = useMergedAttrs(
61
- "flex items-center gap-2"
62
- );
63
- return (_ctx, _cache) => {
64
- return openBlock(), createBlock(unref(PinInputRoot), mergeProps({
65
- modelValue: cells.value,
66
- "onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => cells.value = $event),
67
- type: __props.alphanumeric ? "text" : "number",
68
- mask: __props.mask,
69
- disabled: unref(field).disabled,
70
- otp: "",
71
- class: unref(rootClass)
72
- }, unref(rest), { onComplete }), {
73
- default: withCtx(() => [
74
- (openBlock(true), createElementBlock(Fragment, null, renderList(__props.length, (i) => {
75
- return openBlock(), createBlock(unref(PinInputInput), {
76
- key: i,
77
- index: i - 1,
78
- id: i === 1 ? unref(field).id : void 0,
79
- "aria-invalid": unref(field).invalid || void 0,
80
- "aria-describedby": unref(field).describedBy,
81
- class: normalizeClass([
82
- cellSize[__props.size],
83
- "rounded-control border bg-surface-soft text-center font-semibold text-ink caret-brand",
84
- "outline-none transition-colors duration-[var(--duration-fast)]",
85
- "focus:border-brand focus:ring-2 focus:ring-ring",
86
- "disabled:cursor-not-allowed disabled:opacity-55",
87
- unref(field).invalid ? "border-danger focus:border-danger focus:ring-danger-soft" : "border-line"
88
- ])
89
- }, null, 8, ["index", "id", "aria-invalid", "aria-describedby", "class"]);
90
- }), 128))
91
- ]),
92
- _: 1
93
- }, 16, ["modelValue", "type", "mask", "disabled", "class"]);
94
- };
95
- }
96
- });
97
- export {
98
- _sfc_main as _
99
- };