Skip to content

Commit

Permalink
feat(PinInput): implement component (#2570)
Browse files Browse the repository at this point in the history
Co-authored-by: Max Steinwand <[email protected]>
Co-authored-by: Benjamin Canac <[email protected]>
Co-authored-by: Romain Hamel <[email protected]>
  • Loading branch information
4 people authored Nov 12, 2024
1 parent f516d7b commit 95aa6f6
Show file tree
Hide file tree
Showing 32 changed files with 1,580 additions and 654 deletions.
2 changes: 1 addition & 1 deletion docs/app/components/content/ComponentCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const code = computed(() => {
continue
}

code += ` ${prop?.type.includes('number') ? ':' : ''}${name}="${value}"`
code += ` ${typeof value === 'number' ? ':' : ''}${name}="${value}"`
}
}

Expand Down
13 changes: 10 additions & 3 deletions docs/app/components/content/examples/form/FormExampleElements.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@ const schema = z.object({
radioGroup: z.string().refine(value => value === 'option-2', {
message: 'Select Option 2'
}),
slider: z.number().max(20, { message: 'Must be less than 20' })
slider: z.number().max(20, { message: 'Must be less than 20' }),
pin: z.string().regex(/^\d$/).array().length(5)
})
type Schema = z.output<typeof schema>
type Schema = z.input<typeof schema>
const state = reactive<Partial<Schema>>({})
Expand All @@ -52,7 +53,7 @@ async function onSubmit(event: FormSubmitEvent<any>) {
</script>

<template>
<UForm ref="form" :state="state" :schema="schema" @submit="onSubmit">
<UForm ref="form" :state="state" :schema="schema" class="w-full" @submit="onSubmit">
<div class="grid grid-cols-3 gap-4">
<UFormField label="Input" name="input">
<UInput v-model="state.input" placeholder="[email protected]" class="w-40" />
Expand Down Expand Up @@ -101,6 +102,12 @@ async function onSubmit(event: FormSubmitEvent<any>) {
<UFormField name="radioGroup">
<URadioGroup v-model="state.radioGroup" legend="Radio group" :items="items" />
</UFormField>

<span />

<UFormField name="pin" label="Pin Input" :error-pattern="/(pin)\..*/">
<UPinInput v-model="state.pin" />
</UFormField>
</div>

<div class="flex gap-2 mt-8">
Expand Down
181 changes: 181 additions & 0 deletions docs/content/3.components/pin-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
---
title: PinInput
description: An input element to enter a pin.
links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/PinInput.vue
---

## Usage

Use the `v-model` directive to control the value of the PinInput.

::component-code
---
prettier: true
ignore:
- modelValue
external:
- modelValue
props:
modelValue: []
---
::

Use the `default-value` prop to set the initial value when you do not need to control its state.

::component-code
---
prettier: true
ignore:
- defaultValue
props:
defaultValue: ['1','2','3']
---
::

### Type

Use the `type` prop to change the input type. Defaults to `text`.

::component-code
---
items:
type:
- text
- number
props:
type: 'number'
---
::

::note
When `type` is set to `number`, it will only accept numeric characters.
::

### Mask

Use the `mask` prop to treat the input like a password.

::component-code
---
prettier: true
ignore:
- placeholder
- defaultValue
props:
mask: true
defaultValue: ['1','2','3','4','5']
---
::

### OTP

Use the `otp` prop to enable One-Time Password functionality. When enabled, mobile devices can automatically detect and fill OTP codes from SMS messages or clipboard content, with autocomplete support.

::component-code
---
props:
otp: true
---
::

### Length

Use the `length` prop to change the amount of inputs.

::component-code
---
props:
length: 6
---
::

### Placeholder

Use the `placeholder` prop to set a placeholder text.

::component-code
---
props:
placeholder: '○'
---
::

### Color

Use the `color` prop to change the ring color when the PinInput is focused.

::component-code
---
ignore:
- placeholder
props:
color: neutral
highlight: true
placeholder: '○'
---
::

::note
The `highlight` prop is used here to show the focus state. It's used internally when a validation error occurs.
::

### Variant

Use the `variant` prop to change the variant of the PinInput.

::component-code
---
ignore:
- placeholder
props:
color: neutral
variant: subtle
highlight: false
placeholder: '○'
---
::

### Size

Use the `size` prop to change the size of the PinInput.

::component-code
---
ignore:
- placeholder
props:
size: xl
placeholder: '○'
---
::

### Disabled

Use the `disabled` prop to disable the PinInput.

::component-code
---
ignore:
- placeholder
props:
disabled: true
placeholder: '○'
---
::

## API

### Props

:component-props

### Emits

:component-emits

## Theme

:component-theme
1 change: 1 addition & 0 deletions playground-vue/src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const components = [
'modal',
'navigation-menu',
'pagination',
'pin-input',
'popover',
'progress',
'radio-group',
Expand Down
1 change: 1 addition & 0 deletions playground/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const components = [
'modal',
'navigation-menu',
'pagination',
'pin-input',
'popover',
'progress',
'radio-group',
Expand Down
52 changes: 52 additions & 0 deletions playground/app/pages/components/pin-input.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script setup lang="ts">
import theme from '#build/ui/pin-input'
const sizes = Object.keys(theme.variants.size) as Array<keyof typeof theme.variants.size>
const variants = Object.keys(theme.variants.variant) as Array<keyof typeof theme.variants.variant>
const onComplete = (e: string[]) => {
alert(e.join(''))
}
</script>

<template>
<div class="flex flex-col items-center gap-4">
<div class="flex gap-4">
<UPinInput placeholder="" autofocus @complete="onComplete" />
</div>
<div class="flex items-center gap-4">
<UPinInput v-for="variant in variants" :key="variant" placeholder="" :variant="variant" />
</div>
<div class="flex items-center gap-4">
<UPinInput
v-for="variant in variants"
:key="variant"
placeholder=""
:variant="variant"
color="neutral"
/>
</div>
<div class="flex items-center gap-4">
<UPinInput
v-for="variant in variants"
:key="variant"
placeholder=""
:variant="variant"
color="error"
highlight
/>
</div>
<div class="flex flex-col gap-4">
<UPinInput placeholder="" disabled />
<UPinInput placeholder="" required />
</div>
<div class="flex items-center gap-4">
<UPinInput
v-for="size in sizes"
:key="size"
placeholder=""
:size="size"
/>
</div>
</div>
</template>
95 changes: 95 additions & 0 deletions src/runtime/components/PinInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script lang="ts">
import _appConfig from '#build/app.config'
import theme from '#build/ui/pin-input'
import type { AppConfig } from '@nuxt/schema'
import type { PinInputRootEmits, PinInputRootProps } from 'radix-vue'
import { tv, type VariantProps } from 'tailwind-variants'
import type { PartialString } from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { pinInput: Partial<typeof theme> } }
const pinInput = tv({ extend: tv(theme), ...(appConfig.ui?.pinInput || {}) })
type PinInputVariants = VariantProps<typeof pinInput>
export interface PinInputProps extends Pick<PinInputRootProps, 'defaultValue' | 'disabled' | 'id' | 'mask' | 'modelValue' | 'name' | 'otp' | 'placeholder' | 'required' | 'type'> {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
color?: PinInputVariants['color']
variant?: PinInputVariants['variant']
size?: PinInputVariants['size']
length?: number | string
highlight?: boolean
class?: any
ui?: PartialString<typeof pinInput.slots>
}
export type PinInputEmits = PinInputRootEmits & {
change: [payload: Event]
blur: [payload: Event]
}
</script>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { PinInputInput, PinInputRoot, useForwardPropsEmits } from 'radix-vue'
import { reactivePick } from '@vueuse/core'
import { looseToNumber } from '../utils'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<PinInputProps>(), {
type: 'text',
length: 5
})
const emits = defineEmits<PinInputEmits>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits)
const { emitFormInput, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled } = useFormField<PinInputProps>(props)
const ui = computed(() => pinInput({
color: color.value,
variant: props.variant,
size: size.value,
highlight: highlight.value
}))
const completed = ref(false)
function onComplete(value: string[]) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
emitFormChange()
}
function onBlur(event: FocusEvent) {
if (!event.relatedTarget || completed.value) {
emits('blur', event)
emitFormBlur()
}
}
</script>

<template>
<PinInputRoot
v-bind="rootProps"
:id="id"
:name="name"
:class="ui.root({ class: [props.class, props.ui?.root] })"
@update:model-value="emitFormInput()"
@complete="onComplete"
>
<PinInputInput
v-for="(ids, index) in looseToNumber(props.length)"
:key="ids"
:index="index"
:class="ui.base({ class: props.ui?.base })"
v-bind="$attrs"
:disabled="disabled"
@blur="onBlur"
/>
</PinInputRoot>
</template>
1 change: 1 addition & 0 deletions src/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from '../components/Link.vue'
export * from '../components/Modal.vue'
export * from '../components/NavigationMenu.vue'
export * from '../components/Pagination.vue'
export * from '../components/PinInput.vue'
export * from '../components/Popover.vue'
export * from '../components/Progress.vue'
export * from '../components/RadioGroup.vue'
Expand Down
Loading

0 comments on commit 95aa6f6

Please sign in to comment.