Input group
An input with something before or after it, plus ready-made password and search fields.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add input-grouppnpm dlx ermineui add input-groupyarn dlx ermineui add input-groupbunx ermineui add input-groupThat copies 4 files into src/components/ui/input-group/, and the 1 component it is built from. Files you already have are kept.
Use
import { InputGroup, PasswordInput, SearchInput } from '@/components/ui/input-group'<InputGroup>
<template #prefix>$</template>
<Input v-model="amount" />
<template #suffix>USD</template>
</InputGroup>
<PasswordInput v-model="password" autocomplete="new-password" />
<SearchInput v-model="query" placeholder="Search" />
<TagsInput v-model="emails" name="to" :validate="(tag) => isEmail(tag) ? undefined : 'Not an email address.'" />
<Toggle v-model="bold" aria-label="Bold"><strong>B</strong></Toggle>
<TimePicker v-model="time" :step="15" min="07:00" max="19:00" /> <!-- 'HH:MM', 24-hour -->
<DateTimePicker v-model="startsAt" /> <!-- 'YYYY-MM-DDTHH:MM' -->
<ColorPicker v-model="colour" :presets="['#2a78d6', '#eb6834']" /> <!-- '#rrggbb' -->In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
InputGroup.vue | src/components/ui/input-group/InputGroup.vue |
PasswordInput.vue | src/components/ui/input-group/PasswordInput.vue |
SearchInput.vue | src/components/ui/input-group/SearchInput.vue |
index.ts | src/components/ui/input-group/index.ts |
It is built from: Input. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
InputGroup.vue 107 lines
<template>
<!-- The frame draws the border; the input inside goes bare, so the whole thing reads as one field. -->
<div class="er-input-group">
<span v-if="$slots.prefix" class="er-input-group-addon"><slot name="prefix" /></span>
<slot />
<span v-if="$slots.suffix" class="er-input-group-addon"><slot name="suffix" /></span>
</div>
</template>
<style>
@layer components {
.er-input-group {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
height: var(--er-control-md);
border: 1px solid var(--er-input);
border-radius: var(--er-radius-control);
background: var(--er-input-background);
box-shadow: var(--er-shadow-xs);
transition:
border-color var(--er-duration),
outline-color var(--er-duration);
}
.er-input-group:has(.er-input:focus-visible) {
border-color: var(--er-ring);
outline: 2px solid color-mix(in oklab, var(--er-ring) 45%, transparent);
outline-offset: 0;
}
.er-input-group:has(.er-input[aria-invalid='true']) {
border-color: var(--er-destructive);
}
.er-input-group:has(.er-input:disabled) {
opacity: 0.5;
}
.er-input-group .er-input,
.er-input-group .er-input:focus-visible {
flex: 1;
height: 100%;
border: 0;
background: transparent;
box-shadow: none;
outline: none;
opacity: 1;
}
.er-input-group-addon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 0.25rem;
padding: 0 0.625rem;
color: var(--er-muted-foreground);
font-size: 0.875rem;
white-space: nowrap;
}
.er-input-group-addon:first-child {
padding-right: 0;
}
.er-input-group-addon:last-child {
padding-left: 0;
}
.er-input-group-addon svg {
width: 1rem;
height: 1rem;
}
/* Small buttons that live inside a field: show / hide, clear. */
.er-input-group-button {
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
margin-right: -0.25rem;
padding: 0;
border: 0;
border-radius: calc(var(--er-radius-control) - 2px);
background: transparent;
color: var(--er-muted-foreground);
cursor: pointer;
}
.er-input-group-button:hover {
background: var(--er-accent);
color: var(--er-foreground);
}
.er-input-group-button:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 1px;
}
/* The browser's own clear button would double up with ours. */
.er-input-group .er-input[type='search']::-webkit-search-cancel-button {
appearance: none;
}
}
</style>PasswordInput.vue 36 lines
<script setup lang="ts">
import { ref } from 'vue'
import { Input } from '../input'
import InputGroup from './InputGroup.vue'
// Attributes such as id, autocomplete and aria-invalid belong on the input itself.
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
showLabel?: string
hideLabel?: string
}>(),
{ showLabel: 'Show password', hideLabel: 'Hide password' },
)
const model = defineModel<string>({ default: '' })
const visible = ref(false)
</script>
<template>
<InputGroup>
<Input v-bind="$attrs" v-model="model" :type="visible ? 'text' : 'password'" autocapitalize="off" spellcheck="false" />
<template #suffix>
<!-- A pressed state plus a name that says what a press will do. -->
<button type="button" class="er-input-group-button" :aria-label="visible ? hideLabel : showLabel" :aria-pressed="visible" @click="visible = !visible">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
<path v-if="visible" d="M4 4l16 16" />
</svg>
</button>
</template>
</InputGroup>
</template>SearchInput.vue 51 lines
<script setup lang="ts">
import { ref } from 'vue'
import { Input } from '../input'
import InputGroup from './InputGroup.vue'
// Attributes such as id, placeholder and aria-label belong on the input itself.
defineOptions({ inheritAttrs: false })
withDefaults(
defineProps<{
clearLabel?: string
}>(),
{ clearLabel: 'Clear search' },
)
const model = defineModel<string>({ default: '' })
const root = ref<HTMLElement | null>(null)
function clear() {
model.value = ''
root.value?.querySelector('input')?.focus()
}
function onKeydown(event: KeyboardEvent) {
// Escape empties the field first; only an already empty field lets Escape through (to close a dialog, say).
if (event.key !== 'Escape' || !model.value) return
event.preventDefault()
event.stopPropagation()
clear()
}
</script>
<template>
<div ref="root" class="er-search-input" @keydown="onKeydown">
<InputGroup>
<template #prefix>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.500-3.500" />
</svg>
</template>
<Input v-bind="$attrs" v-model="model" type="search" />
<template v-if="model" #suffix>
<button type="button" class="er-input-group-button" :aria-label="clearLabel" @click="clear">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</template>
</InputGroup>
</div>
</template>index.ts 5 lines
/** Input group — an input with something before or after it, plus ready-made password and search fields. */
export { default as InputGroup } from './InputGroup.vue'
export { default as PasswordInput } from './PasswordInput.vue'
export { default as SearchInput } from './SearchInput.vue'