Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add NeTextInput component #39

Merged
merged 1 commit into from
Apr 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions src/components/NeTextInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<!--
Copyright (C) 2024 Nethesis S.r.l.
SPDX-License-Identifier: GPL-3.0-or-later
-->

<script setup lang="ts">
import { computed, ref, useAttrs } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleExclamation as fasCircleExclamation } from '@fortawesome/free-solid-svg-icons'
import { faEye as fasEye } from '@fortawesome/free-solid-svg-icons'
import { faEyeSlash as fasEyeSlash } from '@fortawesome/free-solid-svg-icons'

const props = defineProps({
label: {
type: String,
default: ''
},
modelValue: {
type: String,
default: ''
},
id: {
type: String,
default: ''
},
placeholder: {
type: String,
default: ''
},
helperText: {
type: String,
default: ''
},
invalidMessage: {
type: String,
default: ''
},
optional: {
type: Boolean
},
isPassword: {
type: Boolean,
default: false
},
showPasswordLabel: {
type: String,
default: 'Show password'
},
hidePasswordLabel: {
type: String,
default: 'Hide password'
},
optionalLabel: {
type: String,
default: 'Optional'
},
disabled: {
type: Boolean,
default: false
}
})

const emit = defineEmits(['update:modelValue'])

// expose focus function
defineExpose({
focus
})

defineOptions({
inheritAttrs: false
})

// add fontawesome icons
library.add(fasCircleExclamation)
library.add(fasEye)
library.add(fasEyeSlash)

const inputBaseStyle =
'block w-full rounded-md border-0 py-1.5 ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 mt-0 disabled:cursor-not-allowed disabled:opacity-50 text-gray-900 bg-white placeholder:text-gray-400 transition-colors duration-200 dark:text-gray-50 dark:bg-gray-950 dark:placeholder:text-gray-500'
const inputValidStyle =
'ring-gray-300 focus:ring-primary-500 dark:ring-gray-600 dark:focus:ring-primary-300'
const inputInvalidStyle =
'pr-10 ring-rose-700 focus:ring-rose-500 dark:ring-rose-500 dark:focus:ring-rose-500'

const descriptionBaseStyle = 'mt-2 text-sm'

let isPasswordVisible = ref(false)

const componentId = computed(() => (props.id ? props.id : uuidv4()))

const inputStyles = computed(() =>
[inputBaseStyle, props.invalidMessage ? inputInvalidStyle : inputValidStyle].join(' ')
)

const inputRef = ref()

const attrs = useAttrs()

const rootAttrs = computed(() => {
const allowed = ['class']
return Object.keys(attrs)
.filter((key) => allowed.includes(key))
.reduce((obj: any, key) => {
obj[key] = attrs[key]
return obj
}, {})
})

const inputAttrs = computed(() => {
const notAllowed = ['class']
return Object.keys(attrs)
.filter((key) => !notAllowed.includes(key))
.reduce((obj: any, key) => {
obj[key] = attrs[key]
return obj
}, {})
})

function emitModelValue(ev: any) {
emit('update:modelValue', ev.target.value)
}

function togglePasswordVisible() {
isPasswordVisible.value = !isPasswordVisible.value
}

function focus() {
inputRef.value.focus()
}
</script>

<template>
<div v-bind="rootAttrs">
<label
v-if="label"
:for="componentId"
class="mb-2 flex items-end justify-between text-sm font-medium leading-6 text-gray-700 dark:text-gray-200"
>
<span>
{{ label }}
<span v-if="$slots.tooltip" class="ml-1">
<slot name="tooltip"></slot>
</span>
</span>
<span v-if="optional" class="ml-2 font-normal">{{ optionalLabel }}</span>
</label>
<div class="relative rounded-md shadow-sm">
<input
:id="componentId"
ref="inputRef"
:type="isPassword && !isPasswordVisible ? 'password' : 'text'"
:value="modelValue"
:class="inputStyles"
:placeholder="placeholder"
:aria-describedby="componentId + '-description'"
v-bind="inputAttrs"
:disabled="disabled"
@input="($event) => emitModelValue($event)"
/>
<!-- show/hide password toggle -->
<div v-if="isPassword" class="absolute inset-y-0 right-0 flex items-center">
<button
type="button"
:disabled="disabled"
:class="['px-3', disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer']"
@click="togglePasswordVisible"
>
<span class="sr-only">{{
isPasswordVisible ? hidePasswordLabel : showPasswordLabel
}}</span>
<font-awesome-icon
:icon="['fas', isPasswordVisible ? 'eye-slash' : 'eye']"
class="h-4 w-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
/>
</button>
</div>
<!-- invalid icon -->
<div
v-else-if="invalidMessage"
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<font-awesome-icon
:icon="['fas', 'circle-exclamation']"
class="h-4 w-4 text-rose-700 dark:text-rose-400"
aria-hidden="true"
/>
</div>
</div>
<!-- invalid message -->
<p
v-if="invalidMessage"
:id="componentId + '-description'"
:class="[descriptionBaseStyle, 'text-rose-700 dark:text-rose-400']"
>
{{ invalidMessage }}
</p>
<!-- helper text -->
<p
v-else-if="helperText"
:id="componentId + '-description'"
:class="[descriptionBaseStyle, 'text-gray-500 dark:text-gray-400']"
>
{{ helperText }}
</p>
</div>
</template>
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export { default as NePaginator } from '@/components/NePaginator.vue'
export { default as NeEmptyState } from '@/components/NeEmptyState.vue'
export { default as NeTabs } from '@/components/NeTabs.vue'
export { default as NeTextArea } from '@/components/NeTextArea.vue'
export { default as NeTextInput } from '@/components/NeTextInput.vue'

// types export
export type { NeComboboxOption } from '@/components/NeCombobox.vue'
Expand Down
132 changes: 132 additions & 0 deletions stories/NeTextInput.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (C) 2024 Nethesis S.r.l.
// SPDX-License-Identifier: GPL-3.0-or-later

import type { Meta, StoryObj } from '@storybook/vue3'

import { NeTextInput, NeTooltip } from '../src/main'

const meta = {
title: 'Control/NeTextInput',
component: NeTextInput,
// default values
args: {
label: 'Label',
modelValue: '',
placeholder: 'Placeholder',
helperText: '',
invalidMessage: '',
optional: false,
disabled: false,
id: '',
isPassword: false,
showPasswordLabel: 'Show password',
hidePasswordLabel: 'Hide password',
optionalLabel: 'Optional'
}
} satisfies Meta<typeof NeTextInput>

export default meta
type Story = StoryObj<typeof meta>

const template = '<NeTextInput v-bind="args" class="max-w-md" />'

export const Default: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: template
}),
args: {}
}

export const HelperText: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: template
}),
args: { helperText: 'Helper text' }
}

export const Invalid: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: template
}),
args: {
invalidMessage: 'Invalid input value'
}
}

export const Optional: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: template
}),
args: { optional: true }
}

export const Disabled: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: template
}),
args: { disabled: true }
}

export const Password: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: template
}),
args: { isPassword: true, label: 'Enter password', placeholder: '' }
}

const typeNumberTemplate = '<NeTextInput v-bind="args" type="number" class="max-w-md" />'

export const TypeNumber: Story = {
render: (args) => ({
components: { NeTextInput },
setup() {
return { args }
},
template: typeNumberTemplate
}),
args: {}
}

const templateWithTooltip =
'<NeTextInput v-bind="args" class="max-w-md">\
<template #tooltip>\
<NeTooltip>\
<template #content>Tooltip</template>\
</NeTooltip>\
</template>\
</NeTextInput>'

export const WithTooltip: Story = {
render: (args) => ({
components: { NeTextInput, NeTooltip },
setup() {
return { args }
},
template: templateWithTooltip
}),
args: {}
}