1 звезда на GitHub = 1 новый компонент.

Поставить звезду
Формыformtagsinputautocompletepills

Tags Autocomplete Input

Поле ввода тегов с выпадающими подсказками автодополнения и удалением чипов.

Живое превью

AstroTypeScript

Использование

Скопируйте и вставьте этот код в ваш React-компонент:

tsx
import { TagsAutocompleteInput } from 'react-component-kit-ui/forms';

<TagsAutocompleteInput id="article-tags" label="Article Tags" initialTags={['astro', 'design']} />

Ручная установка

Если вы не используете пакет npm, создайте файл и вставьте следующий код: src/components/TagsAutocompleteInput.tsx + TagsAutocompleteInput.css:

TagsAutocompleteInput.tsx
'use client';

import { useEffect, useRef } from 'react';
import './TagsAutocompleteInput.css';

/**
 * TagsAutocompleteInput — Tag entry field with pill badges, keyboard deletion, and suggestion dropdown.
 * 
 * @param {string} id - HTML ID for the tag input.
 * @param {string} label - Input label. Default is 'Tags'.
 * @param {string[]} initialTags - Pre-populated list of tags. Default is ['Astro', 'TypeScript'].
 * @param {string[]} suggestions - Autocomplete candidate options.
 * @param {string} placeholder - Input placeholder. Default is 'Add tag & press Enter...'.
 */
export interface TagsAutocompleteInputProps {
  id: string;
  label?: string;
  initialTags?: string[];
  suggestions?: string[];
  placeholder?: string;
}

export function TagsAutocompleteInput({
  id = 'tags-autocomplete',
  label = 'Tags',
  initialTags = ['Astro', 'TypeScript'],
  suggestions = ['React', 'Tailwind', 'Three.js', 'SSR', 'Vite', 'GraphQL'],
  placeholder = 'Add tag & press Enter...',
}: TagsAutocompleteInputProps) {

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    function setupTagsFields() {
        document.querySelectorAll('[data-tags-root]').forEach((root) => {
          const input = root.querySelector('[data-tags-input]') as HTMLInputElement;
          const list = root.querySelector('[data-tags-list]') as HTMLElement;
          const suggestions = root.querySelector('[data-tags-suggestions]') as HTMLElement;
          if (!input || !list) return;
    
          function addTag(text: string) {
            const clean = text.trim();
            if (!clean) return;
    
            // Check duplicate
            const exists = Array.from(list.querySelectorAll('.tag-pill__name')).some(
              (el) => el.textContent?.toLowerCase() === clean.toLowerCase()
            );
            if (exists) return;
    
            const pill = document.createElement('span');
            pill.className = 'tag-pill';
            pill.setAttribute('data-tag', clean);
            pill.innerHTML = `
              <span class="tag-pill__name">${clean}</span>
              <button type="button" class="tag-pill__remove" aria-label="Remove tag ${clean}" data-tag-remove>×</button>
            `;
            list.appendChild(pill);
            input.value = '';
          }
    
          list.addEventListener('click', (e) => {
            const target = e.target as HTMLElement;
            if (target.matches('[data-tag-remove]')) {
              const pill = target.closest('.tag-pill');
              if (pill) pill.remove();
            }
          });
    
          input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' || e.key === ',') {
              e.preventDefault();
              addTag(input.value);
            } else if (e.key === 'Backspace' && !input.value) {
              const last = list.querySelector('.tag-pill:last-child');
              if (last) last.remove();
            }
          });
    
          input.addEventListener('focus', () => {
            if (suggestions) suggestions.hidden = false;
          });
    
          if (suggestions) {
            suggestions.addEventListener('click', (e) => {
              const btn = (e.target as HTMLElement).closest('[data-sug-val]') as HTMLElement;
              if (btn) {
                const val = btn.getAttribute('data-sug-val');
                if (val) addTag(val);
              }
            });
          }
        });
      }
    
      setupTagsFields();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <div className="tags-field" data-tags-root data-rk-ba8ccd="">
        <label htmlFor={id} className="tags-field__label" data-rk-ba8ccd="">{label}</label>
      
        <div className="tags-field__box" data-tags-box data-rk-ba8ccd="">
          <div className="tags-field__list" data-tags-list data-rk-ba8ccd="">
            {initialTags.map((tag, __i: number) => (
              <span key={__i} className="tag-pill" data-tag={tag} data-rk-ba8ccd="">
                <span className="tag-pill__name" data-rk-ba8ccd="">{tag}</span>
                <button type="button" className="tag-pill__remove" aria-label={`Remove tag ${tag}`} data-tag-remove data-rk-ba8ccd="">×</button>
              </span>
            ))}
          </div>
      
          <input type="text" id={id} placeholder={placeholder} className="tags-field__input" data-tags-input autoComplete="off" data-rk-ba8ccd="" />
        </div>
      
        <div className="tags-field__suggestions" data-tags-suggestions hidden data-rk-ba8ccd="">
          {suggestions.map((sug, __i: number) => (
            <button key={__i} type="button" className="sug-item" data-sug-val={sug} data-rk-ba8ccd="">
              + {sug}
            </button>
          ))}
        </div>
      </div>
    </>
  );
}

export default TagsAutocompleteInput;
TagsAutocompleteInput.css
/* TagsAutocompleteInput — ported from astro-component-kit (forms/TagsAutocompleteInput.astro) */

.tags-field:where([data-rk-ba8ccd]) {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2, 0.5rem);
  width: 100%;
  max-width: 440px;
  font-family: var(--font-sans, system-ui, sans-serif);
  position: relative;
}

.tags-field__label:where([data-rk-ba8ccd]) {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--c-text-1, #ffffff);
}

.tags-field__box:where([data-rk-ba8ccd]) {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  padding: 0.5rem 0.75rem;
  background: rgba(255, 255, 255, 0.03);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: var(--r-md, 12px);
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
  min-height: 44px;
}

.tags-field__box:focus-within:where([data-rk-ba8ccd]) {
  border-color: rgba(184, 134, 11, 0.5);
  box-shadow: 0 0 0 3px rgba(184, 134, 11, 0.15);
}

.tags-field__list:where([data-rk-ba8ccd]) {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 6px;
}

.tag-pill:where([data-rk-ba8ccd]) {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 0.25rem 0.65rem;
  background: rgba(184, 134, 11, 0.15);
  border: 1px solid rgba(184, 134, 11, 0.35);
  border-radius: var(--r-full, 9999px);
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--c-accent, #fef3c7);
  user-select: none;
  animation: tags-autocomplete-input-pill-pop 0.2s ease-out;
}

@keyframes tags-autocomplete-input-pill-pop {
  from { transform: scale(0.8); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

.tag-pill__remove:where([data-rk-ba8ccd]) {
  background: transparent;
  border: none;
  color: var(--c-accent, #fef3c7);
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0 2px;
  opacity: 0.7;
  transition: opacity 0.2s;
}

.tag-pill__remove:hover:where([data-rk-ba8ccd]) {
  opacity: 1;
  color: #ef4444;
}

.tags-field__input:where([data-rk-ba8ccd]) {
  flex: 1;
  min-width: 140px;
  background: transparent;
  border: none;
  color: var(--c-text-1, #ffffff);
  font-size: 0.88rem;
  outline: none;
  padding: 0.2rem 0;
}

.tags-field__input:where([data-rk-ba8ccd])::placeholder {
  color: rgba(255, 255, 255, 0.3);
}

.tags-field__suggestions:where([data-rk-ba8ccd]) {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 4px;
  padding: 0.4rem;
  background: rgba(12, 12, 16, 0.9);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: var(--r-sm, 8px);
}

.sug-item:where([data-rk-ba8ccd]) {
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: var(--r-full, 9999px);
  color: var(--c-text-2, #a3a3a3);
  font-size: 0.72rem;
  font-weight: 600;
  padding: 0.2rem 0.6rem;
  cursor: pointer;
  transition: all 0.2s ease;
}

.sug-item:hover:where([data-rk-ba8ccd]) {
  background: rgba(184, 134, 11, 0.2);
  border-color: rgba(184, 134, 11, 0.4);
  color: var(--c-text-1, #ffffff);
}

Краткая информация

Категория
Формы
Имя файла
TagsAutocompleteInput.tsx
Зависимости
Нет — чистый React + CSS
Теги
formtagsinputautocompletepills