orio-ui 1.28.0 → 1.29.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.
Files changed (31) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/ROUTING.md +2 -1
  3. package/dist/agents/component-finder.md +1 -0
  4. package/dist/agents/component-worker.md +1 -0
  5. package/dist/module.json +1 -1
  6. package/dist/runtime/components/Button.USAGE.md +5 -0
  7. package/dist/runtime/components/Button.d.vue.ts +1 -0
  8. package/dist/runtime/components/Button.vue +5 -1
  9. package/dist/runtime/components/Button.vue.d.ts +1 -0
  10. package/dist/runtime/components/ControlElement.d.vue.ts +1 -1
  11. package/dist/runtime/components/ControlElement.vue.d.ts +1 -1
  12. package/dist/runtime/components/Input.USAGE.md +4 -0
  13. package/dist/runtime/components/Input.d.vue.ts +4 -1
  14. package/dist/runtime/components/Input.vue +6 -0
  15. package/dist/runtime/components/Input.vue.d.ts +4 -1
  16. package/dist/runtime/components/NumberInput/Horizontal.vue +5 -0
  17. package/dist/runtime/components/NumberInput/USAGE.md +4 -0
  18. package/dist/runtime/components/NumberInput/Vertical.vue +9 -1
  19. package/dist/runtime/components/NumberInput/index.d.vue.ts +7 -4
  20. package/dist/runtime/components/NumberInput/index.vue +12 -7
  21. package/dist/runtime/components/NumberInput/index.vue.d.ts +7 -4
  22. package/dist/runtime/components/SegmentedInput.USAGE.md +67 -0
  23. package/dist/runtime/components/SegmentedInput.d.vue.ts +22 -0
  24. package/dist/runtime/components/SegmentedInput.vue +114 -0
  25. package/dist/runtime/components/SegmentedInput.vue.d.ts +22 -0
  26. package/dist/runtime/composables/useControlSize.USAGE.md +2 -2
  27. package/dist/runtime/composables/useControlSize.js +12 -0
  28. package/dist/runtime/composables/useUrlSync.d.ts +1 -1
  29. package/dist/runtime/index.d.ts +1 -0
  30. package/dist/runtime/index.js +1 -0
  31. package/package.json +1 -1
package/README.md CHANGED
@@ -18,12 +18,12 @@ orio-ui's shipped, version-pinned agent docs. Details in
18
18
 
19
19
  ## Features
20
20
 
21
- ✨ **58 Components** - Beautiful, accessible components ready to use
21
+ ✨ **59 Components** - Beautiful, accessible components ready to use
22
22
  🎨 **Themeable** - 5 built-in accent themes with light/dark mode support
23
23
  🚀 **Auto-imported** - Works seamlessly with Nuxt's auto-import system
24
24
  📦 **Tree-shakeable** - Only bundle what you use
25
25
  🎯 **TypeScript** - Fully typed for great developer experience
26
- 🧪 **Tested** - 36 test suites for reliability
26
+ 🧪 **Tested** - 38 test suites for reliability
27
27
  📱 **Responsive** - Mobile-first design approach
28
28
  ♿ **Accessible** - ARIA-compliant components
29
29
  🌐 **i18n** - Built-in vue-i18n support with English defaults
@@ -77,7 +77,7 @@ function handleClick() {
77
77
 
78
78
  ## What's Included
79
79
 
80
- ### Components (58)
80
+ ### Components (59)
81
81
 
82
82
  #### Form Controls
83
83
 
@@ -269,7 +269,7 @@ npm run docs:dev
269
269
  orio-ui/
270
270
  ├── src/
271
271
  │ ├── runtime/
272
- │ │ ├── components/ # 58 Vue components
272
+ │ │ ├── components/ # 59 Vue components
273
273
  │ │ ├── composables/ # 15 composables
274
274
  │ │ ├── assets/css/ # Theme CSS files
275
275
  │ │ └── utils/ # Icon registry
@@ -82,6 +82,7 @@ installed version.
82
82
  - `NumberInput/Horizontal.vue` — number input variant with minus/plus buttons flanking the field and press-and-hold repeat. **Read USAGE.md first.**
83
83
  - `NumberInput/Vertical.vue` — number input variant with chevron up/down stacked on the right and press-and-hold repeat. **Read USAGE.md first.**
84
84
  - `RadioButton.vue` — single radio option wrapping ControlElement; group by sharing the same v-model. **Read USAGE.md first.**
85
+ - `SegmentedInput.vue` — fixed-length code input split into auto-advancing per-character segments for OTP / PIN entry. **Read USAGE.md first.**
85
86
  - `Selector.vue` — button-triggered listbox in a popover; supports single and multi-select with string or object options. **Read USAGE.md first.**
86
87
  - `SwitchButton.vue` — boolean on/off pill button (not a sliding switch) wrapping ControlElement; click/Enter/Space toggle. **Read USAGE.md first.**
87
88
  - `TaggableSelector.vue` — multi-select Selector that renders chosen options as tag chips in the trigger. **Read USAGE.md first.**
@@ -116,7 +117,7 @@ installed version.
116
117
 
117
118
  ### Composables
118
119
  - `useApi` — thin typed wrapper around ofetch's `$fetch` for GET/POST/PUT/DELETE/PATCH requests.
119
- - `useControlSize` — provide/inject `ControlSize` (sm/md/lg/xl) and read a CSS-var token bag for the active size. **Read USAGE.md first.**
120
+ - `useControlSize` — provide/inject `ControlSize` (xs/sm/md/lg/xl) and read a CSS-var token bag for the active size. **Read USAGE.md first.**
120
121
  - `useDecimalFormatter` — parse numeric strings (US or EU separator) and format via Intl.NumberFormat with locale defaults. **Read USAGE.md first.**
121
122
  - `useFilter` — named filter group with v-bind bags, $-helpers, $active chips, $clearAll, optional URL sync via lazy import. **Read USAGE.md first.**
122
123
  - `useFuzzySearch` — typed Fuse.js wrapper that returns a computed list of matched items (strings or objects).
@@ -55,6 +55,7 @@ not edit by hand.
55
55
  - **number input horizontal, minus-plus stepper, quantity stepper** → `NumberInput/Horizontal.vue` (read USAGE.md first)
56
56
  - **number input vertical, chevron stepper, stacked-arrow numeric input** → `NumberInput/Vertical.vue` (read USAGE.md first)
57
57
  - **radio, radio button, single-choice from group** → `RadioButton.vue` (read USAGE.md first)
58
+ - **OTP input, one-time password, verification code, PIN code, segmented code boxes** → `SegmentedInput.vue` (read USAGE.md first)
58
59
  - **select, dropdown, combobox, listbox picker, single or multi-select** → `Selector.vue` (read USAGE.md first)
59
60
  - **toggle, on/off switch, pill toggle, boolean button** → `SwitchButton.vue` (read USAGE.md first)
60
61
  - **multi-select with tag chips, taggable selector, chip picker** → `TaggableSelector.vue` (read USAGE.md first)
@@ -50,6 +50,7 @@ not edit by hand.
50
50
  - **number input horizontal, minus-plus stepper, quantity stepper** → `NumberInput/Horizontal.vue` (read USAGE.md first)
51
51
  - **number input vertical, chevron stepper, stacked-arrow numeric input** → `NumberInput/Vertical.vue` (read USAGE.md first)
52
52
  - **radio, radio button, single-choice from group** → `RadioButton.vue` (read USAGE.md first)
53
+ - **OTP input, one-time password, verification code, PIN code, segmented code boxes** → `SegmentedInput.vue` (read USAGE.md first)
53
54
  - **select, dropdown, combobox, listbox picker, single or multi-select** → `Selector.vue` (read USAGE.md first)
54
55
  - **toggle, on/off switch, pill toggle, boolean button** → `SwitchButton.vue` (read USAGE.md first)
55
56
  - **multi-select with tag chips, taggable selector, chip picker** → `TaggableSelector.vue` (read USAGE.md first)
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0 || ^4.0.0"
6
6
  },
7
- "version": "1.28.0",
7
+ "version": "1.29.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -27,6 +27,9 @@ often used standalone.
27
27
  - **Icon-only mode is auto-detected.** When an icon is present and the
28
28
  default slot is empty, `.icon-only` is applied → `aspect-ratio: 1`,
29
29
  `line-height: 0`. No need to pass a prop.
30
+ - **`pill` prop**: `false` by default. When `true`, swaps the button's
31
+ `border-radius` to `var(--border-radius-pill)` (fully rounded),
32
+ overriding the size-driven `--control-radius`. No effect when omitted.
30
33
  - **`disabled` blocks `click` and `mousedown` emits.** `mouseup` and
31
34
  `mouseleave` always fire (so press-and-hold callers can release
32
35
  state).
@@ -67,6 +70,8 @@ often used standalone.
67
70
  </orio-button>
68
71
 
69
72
  <orio-button variant="subdued" icon="close" aria-label="Close" />
73
+
74
+ <orio-button pill icon="plus" aria-label="Add" />
70
75
  </template>
71
76
  ```
72
77
 
@@ -3,6 +3,7 @@ interface Props extends ControlProps {
3
3
  variant?: "primary" | "secondary" | "subdued";
4
4
  icon?: string;
5
5
  loading?: boolean;
6
+ pill?: boolean;
6
7
  }
7
8
  declare var __VLS_13: {}, __VLS_20: {}, __VLS_22: {};
8
9
  type __VLS_Slots = {} & {
@@ -4,6 +4,7 @@ const props = defineProps({
4
4
  variant: { type: String, required: false, default: "primary" },
5
5
  icon: { type: String, required: false },
6
6
  loading: { type: Boolean, required: false },
7
+ pill: { type: Boolean, required: false },
7
8
  appearance: { type: String, required: false },
8
9
  error: { type: [String, null], required: false },
9
10
  group: { type: Boolean, required: false },
@@ -47,7 +48,7 @@ function onMouseleave(event) {
47
48
  <orio-control-element v-slot="{ control }" v-bind="props">
48
49
  <button
49
50
  v-bind="{ ...$attrs, ...control }"
50
- :class="[variant, 'gradient-hover', { 'icon-only': isIconOnly }]"
51
+ :class="[variant, 'gradient-hover', { 'icon-only': isIconOnly, pill }]"
51
52
  @click="click"
52
53
  @mousedown="onMousedown"
53
54
  @mouseup="onMouseup"
@@ -85,6 +86,9 @@ button.icon-only {
85
86
  line-height: 0;
86
87
  aspect-ratio: 1;
87
88
  }
89
+ button.pill {
90
+ border-radius: var(--border-radius-pill);
91
+ }
88
92
  button:disabled, button:disabled:hover {
89
93
  background-color: var(--color-accent-soft-base);
90
94
  color: var(--color-muted);
@@ -3,6 +3,7 @@ interface Props extends ControlProps {
3
3
  variant?: "primary" | "secondary" | "subdued";
4
4
  icon?: string;
5
5
  loading?: boolean;
6
+ pill?: boolean;
6
7
  }
7
8
  declare var __VLS_13: {}, __VLS_20: {}, __VLS_22: {};
8
9
  type __VLS_Slots = {} & {
@@ -1,5 +1,5 @@
1
1
  export type ControlLayout = "vertical" | "horizontal";
2
- export type ControlSize = "sm" | "md" | "lg" | "xl";
2
+ export type ControlSize = "xs" | "sm" | "md" | "lg" | "xl";
3
3
  /**
4
4
  * A11y + form attrs that flow from the caller through ControlElement to the
5
5
  * actual interactive element via the `control` slot prop. Never rendered on
@@ -1,5 +1,5 @@
1
1
  export type ControlLayout = "vertical" | "horizontal";
2
- export type ControlSize = "sm" | "md" | "lg" | "xl";
2
+ export type ControlSize = "xs" | "sm" | "md" | "lg" | "xl";
3
3
  /**
4
4
  * A11y + form attrs that flow from the caller through ControlElement to the
5
5
  * actual interactive element via the `control` slot prop. Never rendered on
@@ -26,6 +26,10 @@ invariants: true
26
26
  on the underlying `<input>`.
27
27
  - **v-model is `string`** (default `""`). For numeric input use
28
28
  `<orio-number-input>` instead.
29
+ - **`defineExpose({ input, focused })`** — `input` is the template ref to the
30
+ native `<input>`; `focused` is the **writable** `Ref<boolean>` from
31
+ `useFocus`. Reading reflects focus state; assigning `true`/`false` focuses or
32
+ blurs the element. Drive programmatic focus via `focused`, not `input.focus()`.
29
33
 
30
34
  ## Gotchas
31
35
 
@@ -14,7 +14,10 @@ type __VLS_Slots = {} & {
14
14
  } & {
15
15
  after?: (props: typeof __VLS_10) => any;
16
16
  };
17
- declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
17
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {
18
+ input: Readonly<import("vue").ShallowRef<HTMLInputElement | null, HTMLInputElement | null>>;
19
+ focused: import("vue").Ref<boolean, boolean>;
20
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
18
21
  "update:modelValue": (value: string) => any;
19
22
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
20
23
  "onUpdate:modelValue"?: ((value: string) => any) | undefined;
@@ -1,4 +1,6 @@
1
1
  <script setup>
2
+ import { useTemplateRef } from "vue";
3
+ import { useFocus } from "@vueuse/core";
2
4
  const props = defineProps({
3
5
  layout: { type: String, required: false, default: "vertical" },
4
6
  appearance: { type: String, required: false },
@@ -15,7 +17,10 @@ const props = defineProps({
15
17
  name: { type: String, required: false },
16
18
  ariaLabel: { type: String, required: false }
17
19
  });
20
+ const input = useTemplateRef("input");
21
+ const { focused } = useFocus(input);
18
22
  const modelValue = defineModel({ type: String, ...{ default: "" } });
23
+ defineExpose({ input, focused });
19
24
  </script>
20
25
 
21
26
  <template>
@@ -27,6 +32,7 @@ const modelValue = defineModel({ type: String, ...{ default: "" } });
27
32
  >
28
33
  <slot name="before" />
29
34
  <input
35
+ ref="input"
30
36
  v-model="modelValue"
31
37
  type="text"
32
38
  v-bind="{ ...$attrs, ...control }"
@@ -14,7 +14,10 @@ type __VLS_Slots = {} & {
14
14
  } & {
15
15
  after?: (props: typeof __VLS_10) => any;
16
16
  };
17
- declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
17
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {
18
+ input: Readonly<import("vue").ShallowRef<HTMLInputElement | null, HTMLInputElement | null>>;
19
+ focused: import("vue").Ref<boolean, boolean>;
20
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
18
21
  "update:modelValue": (value: string) => any;
19
22
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
20
23
  "onUpdate:modelValue"?: ((value: string) => any) | undefined;
@@ -31,6 +31,7 @@ const { pressAndHold, stop } = usePressAndHold();
31
31
  appearance="minimal"
32
32
  icon="minus"
33
33
  variant="subdued"
34
+ size="sm"
34
35
  :disabled="isAtMin || disabled"
35
36
  @mousedown="pressAndHold(decrease)"
36
37
  @mouseup="stop"
@@ -40,6 +41,7 @@ const { pressAndHold, stop } = usePressAndHold();
40
41
  appearance="minimal"
41
42
  icon="plus"
42
43
  variant="subdued"
44
+ size="sm"
43
45
  :disabled="isAtMax || disabled"
44
46
  @mousedown="pressAndHold(increase)"
45
47
  @mouseup="stop"
@@ -65,4 +67,7 @@ const { pressAndHold, stop } = usePressAndHold();
65
67
  right: 0;
66
68
  text-align: center;
67
69
  }
70
+ .horizontal :deep(.control-group) {
71
+ z-index: 2;
72
+ }
68
73
  </style>
@@ -40,6 +40,10 @@ invariants: true
40
40
  prop bag.
41
41
  - **`$attrs` is spread before `control`** on the inner `<input>`, same as
42
42
  Input.
43
+ - **`defineExpose({ input, focused })`** — `input` is the template ref to the
44
+ native `<input>`; `focused` is the **writable** `Ref<boolean>` from
45
+ `useFocus`. Reading reflects focus state; assigning `true`/`false` focuses or
46
+ blurs the element. Drive programmatic focus via `focused`, not `input.focus()`.
43
47
 
44
48
  ## Gotchas
45
49
 
@@ -31,6 +31,8 @@ const { pressAndHold, stop } = usePressAndHold();
31
31
  appearance="minimal"
32
32
  icon="chevron-up"
33
33
  variant="subdued"
34
+ size="xs"
35
+ pill
34
36
  :disabled="isAtMax || disabled"
35
37
  @mousedown="pressAndHold(increase)"
36
38
  @mouseup="stop"
@@ -40,6 +42,8 @@ const { pressAndHold, stop } = usePressAndHold();
40
42
  appearance="minimal"
41
43
  icon="chevron-down"
42
44
  variant="subdued"
45
+ size="xs"
46
+ pill
43
47
  :disabled="isAtMin || disabled"
44
48
  @mousedown="pressAndHold(decrease)"
45
49
  @mouseup="stop"
@@ -52,10 +56,14 @@ const { pressAndHold, stop } = usePressAndHold();
52
56
  <style scoped>
53
57
  .vertical :deep(.controls) {
54
58
  flex-direction: column;
55
- justify-content: space-around;
59
+ justify-content: center;
60
+ gap: 0;
56
61
  right: 3px;
57
62
  left: auto;
58
63
  }
64
+ .vertical :deep(.controls button) {
65
+ padding-block: 0;
66
+ }
59
67
  .vertical :deep(.slot-wrapper) {
60
68
  line-height: 0;
61
69
  }
@@ -11,7 +11,7 @@ type __VLS_Props = NumberInputProps;
11
11
  declare function increase(): void;
12
12
  declare function decrease(): void;
13
13
  type __VLS_ModelProps = {
14
- modelValue?: number;
14
+ modelValue?: number | null;
15
15
  };
16
16
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
17
17
  declare var __VLS_8: {
@@ -23,10 +23,13 @@ declare var __VLS_8: {
23
23
  type __VLS_Slots = {} & {
24
24
  controls?: (props: typeof __VLS_8) => any;
25
25
  };
26
- declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
27
- "update:modelValue": (value: number) => any;
26
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {
27
+ input: Readonly<import("vue").ShallowRef<HTMLInputElement | null, HTMLInputElement | null>>;
28
+ focused: import("vue").Ref<boolean, boolean>;
29
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
30
+ "update:modelValue": (value: number | null) => any;
28
31
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
29
- "onUpdate:modelValue"?: ((value: number) => any) | undefined;
32
+ "onUpdate:modelValue"?: ((value: number | null) => any) | undefined;
30
33
  }>, {
31
34
  layout: InputLayout;
32
35
  step: number;
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
- import { computed, toRefs } from "vue";
2
+ import { computed, toRefs, useTemplateRef } from "vue";
3
+ import { useFocus } from "@vueuse/core";
3
4
  const props = defineProps({
4
5
  layout: { type: String, required: false, default: "vertical" },
5
6
  min: { type: Number, required: false, default: void 0 },
@@ -21,9 +22,11 @@ const props = defineProps({
21
22
  ariaLabel: { type: String, required: false }
22
23
  });
23
24
  const { min, max, step, decimalPlaces } = toRefs(props);
24
- const modelValue = defineModel({ type: Number, ...{ default: 0 } });
25
+ const modelValue = defineModel({ type: [Number, null], ...{ default: 0 } });
26
+ const input = useTemplateRef("input");
27
+ const { focused } = useFocus(input);
25
28
  function setValidatedValue(value) {
26
- let finalValue = value;
29
+ let finalValue = value ?? 0;
27
30
  if (Number.isFinite(max.value) && finalValue > max.value) {
28
31
  finalValue = max.value;
29
32
  }
@@ -37,16 +40,16 @@ function onBlur() {
37
40
  setValidatedValue(modelValue.value);
38
41
  }
39
42
  function increase() {
40
- setValidatedValue(modelValue.value + step.value);
43
+ setValidatedValue((modelValue.value ?? 0) + step.value);
41
44
  }
42
45
  function decrease() {
43
- setValidatedValue(modelValue.value - step.value);
46
+ setValidatedValue((modelValue.value ?? 0) - step.value);
44
47
  }
45
48
  const isAtMax = computed(
46
- () => Number.isFinite(max.value) && modelValue.value >= max.value
49
+ () => Number.isFinite(max.value) && (modelValue.value ?? 0) >= max.value
47
50
  );
48
51
  const isAtMin = computed(
49
- () => Number.isFinite(min.value) && modelValue.value <= min.value
52
+ () => Number.isFinite(min.value) && (modelValue.value ?? 0) <= min.value
50
53
  );
51
54
  const controlProps = computed(() => {
52
55
  const {
@@ -64,6 +67,7 @@ const slotExpose = computed(() => ({
64
67
  isAtMax: isAtMax.value,
65
68
  isAtMin: isAtMin.value
66
69
  }));
70
+ defineExpose({ input, focused });
67
71
  </script>
68
72
 
69
73
  <template>
@@ -76,6 +80,7 @@ const slotExpose = computed(() => ({
76
80
  <div class="wrapper">
77
81
  <input
78
82
  v-bind="{ ...$attrs, ...control }"
83
+ ref="input"
79
84
  v-model="modelValue"
80
85
  type="number"
81
86
  class="number-input"
@@ -11,7 +11,7 @@ type __VLS_Props = NumberInputProps;
11
11
  declare function increase(): void;
12
12
  declare function decrease(): void;
13
13
  type __VLS_ModelProps = {
14
- modelValue?: number;
14
+ modelValue?: number | null;
15
15
  };
16
16
  type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
17
17
  declare var __VLS_8: {
@@ -23,10 +23,13 @@ declare var __VLS_8: {
23
23
  type __VLS_Slots = {} & {
24
24
  controls?: (props: typeof __VLS_8) => any;
25
25
  };
26
- declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
27
- "update:modelValue": (value: number) => any;
26
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {
27
+ input: Readonly<import("vue").ShallowRef<HTMLInputElement | null, HTMLInputElement | null>>;
28
+ focused: import("vue").Ref<boolean, boolean>;
29
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
30
+ "update:modelValue": (value: number | null) => any;
28
31
  }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
29
- "onUpdate:modelValue"?: ((value: number) => any) | undefined;
32
+ "onUpdate:modelValue"?: ((value: number | null) => any) | undefined;
30
33
  }>, {
31
34
  layout: InputLayout;
32
35
  step: number;
@@ -0,0 +1,67 @@
1
+ ---
2
+ kind: component
3
+ category: Form inputs
4
+ purpose: OTP input, one-time password, verification code, PIN code, segmented code boxes
5
+ short: fixed-length code input split into auto-advancing per-character segments for OTP / PIN entry
6
+ invariants: true
7
+ ---
8
+
9
+ # SegmentedInput — agent-only invariants
10
+
11
+ `<orio-segmented-input>` renders `segments` × `<orio-input>` boxes and joins
12
+ them into a single v-model string. Built for OTP / verification-code entry.
13
+
14
+ ## Invariants
15
+
16
+ - **Does NOT extend `ControlProps`** — unlike other form inputs there is no
17
+ `label`, `layout`, `disabled`, or ControlElement wrapper. Add your own
18
+ label / a11y wrapper in the consumer.
19
+ - **v-model is the plain concatenation** of all segment values
20
+ (`string | number`, default `""`). External model writes re-split across
21
+ segments (`segmentSize` characters each).
22
+ - **Sparse fills collapse.** Segments `["1", "", "3"]` join to `"13"`, which
23
+ immediately re-splits as `["1", "3", ""]` — characters shift left past
24
+ empty segments. The model can never hold positional gaps.
25
+ - **Keyboard handling is a window-level `onKeyDown`** that bails unless one
26
+ of its own segments is focused. Auto-advance happens on keydown when the
27
+ focused segment is already full (the key lands in the next segment);
28
+ Backspace on an empty segment moves focus back.
29
+ - **`type="number"`** (default) `preventDefault`s non-digit printable keys
30
+ and sets `inputmode="numeric"` on every segment. Modifier shortcuts
31
+ (Cmd/Ctrl/Alt) and navigation keys pass through. `type="string"` accepts
32
+ anything.
33
+ - **`start` / `end` events** fire when focus would step before the first
34
+ segment (Backspace) or past the last one (typed into the final full
35
+ segment). Use `end` for auto-submit.
36
+ - The first segment has `autocomplete="one-time-code"`; the rest are
37
+ `autocomplete="off"`.
38
+ - **Paste is intercepted** (`@paste.prevent` on the wrapper): the pasted
39
+ text replaces `modelValue` wholesale — digits-only filtered for
40
+ `type="number"`, trimmed for `type="string"`, truncated to
41
+ `segments × segmentSize`. A paste that fills every segment emits `end`.
42
+
43
+ ## Gotchas
44
+
45
+ - **Paste replaces the whole value**, regardless of which segment is
46
+ focused — it does not insert at the focused position.
47
+ - **SMS autofill** (`one-time-code`) inserts via the input value setter, not
48
+ a paste event — it still lands in segment 0 and is truncated by
49
+ `maxlength`.
50
+ - `segments` / `segmentSize` accept `number | string` (loose HTML-attr
51
+ usage) and are `Number()`-coerced internally.
52
+ - Programmatic focus of a segment goes through the exposed `focused` ref of
53
+ `Input.vue` (`inputComponent.focused = true`), not `element.focus()`.
54
+ - Multiple instances on one page are safe — each window listener no-ops
55
+ unless one of its own segments is focused.
56
+
57
+ ## Quick reference
58
+
59
+ ```vue
60
+ <orio-segmented-input
61
+ v-model="code"
62
+ :segments="6"
63
+ :segment-size="1"
64
+ type="number"
65
+ @end="submitCode()"
66
+ />
67
+ ```
@@ -0,0 +1,22 @@
1
+ interface Props {
2
+ segments?: number | string;
3
+ segmentSize?: number | string;
4
+ type?: "number" | "string";
5
+ }
6
+ type __VLS_Props = Props;
7
+ type __VLS_ModelProps = {
8
+ modelValue?: string | number;
9
+ };
10
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ "update:modelValue": (value: string | number) => any;
13
+ } & {
14
+ end: () => any;
15
+ start: () => any;
16
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
17
+ onEnd?: (() => any) | undefined;
18
+ onStart?: (() => any) | undefined;
19
+ "onUpdate:modelValue"?: ((value: string | number) => any) | undefined;
20
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
+ declare const _default: typeof __VLS_export;
22
+ export default _default;
@@ -0,0 +1,114 @@
1
+ <script setup>
2
+ import { ref, computed, watch, watchEffect } from "vue";
3
+ import Input from "./Input.vue";
4
+ import { useTemplateRef } from "vue";
5
+ import { onKeyDown } from "@vueuse/core";
6
+ const { segments = 6, segmentSize = 1, type = "number" } = defineProps({
7
+ segments: { type: [Number, String], required: false },
8
+ segmentSize: { type: [Number, String], required: false },
9
+ type: { type: String, required: false }
10
+ });
11
+ const emit = defineEmits(["start", "end"]);
12
+ const modelValue = defineModel({ type: [String, Number], ...{ default: "" } });
13
+ function applyModel(index) {
14
+ return String(modelValue.value).slice(
15
+ index * Number(segmentSize),
16
+ index * Number(segmentSize) + Number(segmentSize)
17
+ );
18
+ }
19
+ function setupValue() {
20
+ return Array.from(
21
+ { length: Number(segments) },
22
+ (_, index) => applyModel(index)
23
+ );
24
+ }
25
+ const inputComponents = useTemplateRef("inputComponents");
26
+ const inputComponentsModel = ref(setupValue());
27
+ function focusSegment(index) {
28
+ const target = inputComponents.value?.[index];
29
+ if (target) target.focused = true;
30
+ }
31
+ const focusState = computed(() => {
32
+ const currentIndex = inputComponents.value?.findIndex(
33
+ (inputComponent) => inputComponent?.focused
34
+ );
35
+ if (currentIndex === void 0 || currentIndex === -1)
36
+ return { currentIndex: void 0, focusedElement: null };
37
+ const focusedElement = inputComponents.value?.[currentIndex];
38
+ return { currentIndex, focusedElement };
39
+ });
40
+ function focusMove(delta) {
41
+ if (focusState.value.currentIndex === void 0) return;
42
+ let newIndex = focusState.value.currentIndex + delta;
43
+ const maxIndex = inputComponents.value?.length ?? 0;
44
+ if (newIndex >= maxIndex - 1) {
45
+ emit("end");
46
+ newIndex = maxIndex - 1;
47
+ }
48
+ if (newIndex <= 0) {
49
+ emit("start");
50
+ newIndex = 0;
51
+ }
52
+ focusSegment(newIndex);
53
+ }
54
+ onKeyDown(true, (event) => {
55
+ const { currentIndex } = focusState.value;
56
+ if (currentIndex === void 0) return;
57
+ if (event.key === "Backspace") {
58
+ if (inputComponentsModel.value[currentIndex]?.length === 0) focusMove(-1);
59
+ return;
60
+ }
61
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
62
+ if (event.key.length > 1) return;
63
+ if (type === "number" && !/^\d$/.test(event.key)) {
64
+ event.preventDefault();
65
+ return;
66
+ }
67
+ const currentValue = inputComponentsModel.value[currentIndex] ?? "";
68
+ if (currentValue.length >= Number(segmentSize)) focusMove(1);
69
+ });
70
+ function handlePaste(event) {
71
+ const pastedText = event.clipboardData?.getData("text") ?? "";
72
+ const sanitizedText = type === "number" ? pastedText.replace(/\D/g, "") : pastedText.trim();
73
+ if (!sanitizedText) return;
74
+ const capacity = Number(segments) * Number(segmentSize);
75
+ modelValue.value = sanitizedText.slice(0, capacity);
76
+ if (sanitizedText.length >= capacity) emit("end");
77
+ }
78
+ watch(
79
+ () => inputComponentsModel.value.join(""),
80
+ (joinedValue) => {
81
+ modelValue.value = joinedValue;
82
+ }
83
+ );
84
+ watchEffect(() => {
85
+ inputComponentsModel.value = setupValue();
86
+ });
87
+ </script>
88
+
89
+ <template>
90
+ <div class="segmented-input" @paste.prevent="handlePaste">
91
+ <Input
92
+ v-for="(segment, index) in inputComponentsModel"
93
+ :key="index"
94
+ ref="inputComponents"
95
+ v-model="inputComponentsModel[index]"
96
+ :maxlength="segmentSize"
97
+ :inputmode="type === 'number' ? 'numeric' : void 0"
98
+ :autocomplete="index === 0 ? 'one-time-code' : 'off'"
99
+ />
100
+ </div>
101
+ </template>
102
+
103
+ <style scoped>
104
+ .segmented-input {
105
+ display: flex;
106
+ }
107
+ .segmented-input > div {
108
+ flex: 1 1 0;
109
+ min-width: 0;
110
+ }
111
+ .segmented-input :deep(input) {
112
+ text-align: center;
113
+ }
114
+ </style>
@@ -0,0 +1,22 @@
1
+ interface Props {
2
+ segments?: number | string;
3
+ segmentSize?: number | string;
4
+ type?: "number" | "string";
5
+ }
6
+ type __VLS_Props = Props;
7
+ type __VLS_ModelProps = {
8
+ modelValue?: string | number;
9
+ };
10
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ "update:modelValue": (value: string | number) => any;
13
+ } & {
14
+ end: () => any;
15
+ start: () => any;
16
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
17
+ onEnd?: (() => any) | undefined;
18
+ onStart?: (() => any) | undefined;
19
+ "onUpdate:modelValue"?: ((value: string | number) => any) | undefined;
20
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
+ declare const _default: typeof __VLS_export;
22
+ export default _default;
@@ -2,14 +2,14 @@
2
2
  kind: composable
3
3
  category: Composables
4
4
  purpose: control size tokens, sizing tokens for form controls, control variant sizing
5
- short: provide/inject `ControlSize` (sm/md/lg/xl) and read a CSS-var token bag for the active size
5
+ short: provide/inject `ControlSize` (xs/sm/md/lg/xl) and read a CSS-var token bag for the active size
6
6
  invariants: true
7
7
  ---
8
8
 
9
9
  # useControlSize — agent-only invariants
10
10
 
11
11
  This composable owns the **CSS variable bag** that maps a `ControlSize`
12
- (`sm` / `md` / `lg` / `xl`) to concrete padding, font, gap, radius, and
12
+ (`xs` / `sm` / `md` / `lg` / `xl`) to concrete padding, font, gap, radius, and
13
13
  icon-size tokens. Used internally by ControlElement and friends.
14
14
 
15
15
  ## Invariants
@@ -6,6 +6,18 @@ import {
6
6
  } from "vue";
7
7
  const CONTROL_SIZE_KEY = Symbol("control-size");
8
8
  const sizeTokens = {
9
+ xs: {
10
+ "--control-font-size": "var(--font-sm)",
11
+ "--control-label-font-size": "var(--font-xs)",
12
+ "--control-py": "0.125rem",
13
+ "--control-px": "0.25rem",
14
+ "--control-gap": "0.125rem",
15
+ "--control-radius": "var(--border-radius-sm)",
16
+ "--control-icon-size": "0.625rem",
17
+ "--control-inner-block-start": "0.85rem",
18
+ "--control-inner-block-end": "0.1rem",
19
+ "--control-label-block-start": "0.15rem"
20
+ },
9
21
  sm: {
10
22
  "--control-font-size": "var(--font-sm)",
11
23
  "--control-label-font-size": "var(--font-xs)",
@@ -1,4 +1,4 @@
1
- import { Ref } from "vue";
1
+ import { type Ref } from "vue";
2
2
  /**
3
3
  * Syncs a reactive object's keys to/from URL query params.
4
4
  *
@@ -3,6 +3,7 @@ export { default as Button } from "./components/Button.vue.js";
3
3
  export { default as Form, type FormProps } from "./components/Form.vue.js";
4
4
  export { default as NavButton } from "./components/NavButton.vue.js";
5
5
  export { default as Input } from "./components/Input.vue.js";
6
+ export { default as SegmentedInput } from "./components/SegmentedInput.vue.js";
6
7
  export { default as NumberInput } from "./components/NumberInput/index.vue.js";
7
8
  export { default as NumberInputVertical } from "./components/NumberInput/Vertical.vue.js";
8
9
  export { default as NumberInputHorizontal } from "./components/NumberInput/Horizontal.vue.js";
@@ -3,6 +3,7 @@ export { default as Button } from "./components/Button.vue";
3
3
  export { default as Form } from "./components/Form.vue";
4
4
  export { default as NavButton } from "./components/NavButton.vue";
5
5
  export { default as Input } from "./components/Input.vue";
6
+ export { default as SegmentedInput } from "./components/SegmentedInput.vue";
6
7
  export { default as NumberInput } from "./components/NumberInput/index.vue";
7
8
  export { default as NumberInputVertical } from "./components/NumberInput/Vertical.vue";
8
9
  export { default as NumberInputHorizontal } from "./components/NumberInput/Horizontal.vue";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orio-ui",
3
- "version": "1.28.0",
3
+ "version": "1.29.0",
4
4
  "description": "Modern Nuxt component library with theme support",
5
5
  "type": "module",
6
6
  "main": "./dist/module.mjs",