utilikit-1.0.0/modules/utilikit_playground/js/utilikit.playground-autocomplete.js

modules/utilikit_playground/js/utilikit.playground-autocomplete.js
/**
 * @file
 * Dynamic autocomplete functionality for UtiliKit Playground.
 *
 * Provides intelligent autocomplete suggestions for UtiliKit utility classes
 * based on rule types, CSS properties, and common values. Includes keyboard
 * navigation, visual selection, and real-time filtering.
 */

(function(Drupal, once) {
  'use strict';

  /**
   * UtiliKit rules definition matching PHP rules.
   *
   * This object defines all available UtiliKit utility prefixes and their
   * corresponding CSS properties, value types, and behavioral flags.
   * Should ideally be passed from Drupal settings for consistency.
   *
   * @type {Object.<string, Object>}
   *
   * @property {string} css - The CSS property name this rule affects
   * @property {string} group - The functional group for organization
   * @property {boolean} [sides] - Whether rule supports directional variants
   * @property {boolean} [isNumericFlexible] - Accepts numeric + unit values
   * @property {boolean} [allowAuto] - Accepts 'auto' as a value
   * @property {boolean} [isKeyword] - Uses predefined keyword values
   * @property {boolean} [isColor] - Accepts color hex values
   * @property {boolean} [isInteger] - Accepts integer values only
   * @property {boolean} [isOpacity] - Special handling for opacity values
   * @property {boolean} [isDecimalFixed] - Accepts decimal values
   * @property {string} [isTransform] - Transform type ('rotate', 'scale')
   * @property {boolean} [isGridTrackList] - CSS Grid track definitions
   * @property {boolean} [isRange] - Range values like grid spans or ratios
   */
  const utilikitRules = {
    // Box Model
    pd: { css: 'padding', sides: true, isNumericFlexible: true, group: 'Box Model' },
    mg: { css: 'margin', sides: true, isNumericFlexible: true, allowAuto: true, group: 'Box Model' },
    bw: { css: 'borderWidth', sides: true, isNumericFlexible: true, group: 'Box Model' },
    br: { css: 'borderRadius', sides: true, isNumericFlexible: true, group: 'Box Model' },
    bs: { css: 'borderStyle', isKeyword: true, group: 'Box Model' },

    // Colors
    bc: { css: 'borderColor', isColor: true, group: 'Colors' },
    bg: { css: 'backgroundColor', isColor: true, group: 'Colors' },
    tc: { css: 'color', isColor: true, group: 'Colors' },

    // Sizing
    wd: { css: 'width', isNumericFlexible: true, group: 'Sizing' },
    ht: { css: 'height', isNumericFlexible: true, group: 'Sizing' },
    xw: { css: 'maxWidth', isNumericFlexible: true, group: 'Sizing' },
    nw: { css: 'minWidth', isNumericFlexible: true, group: 'Sizing' },
    xh: { css: 'maxHeight', isNumericFlexible: true, group: 'Sizing' },
    nh: { css: 'minHeight', isNumericFlexible: true, group: 'Sizing' },

    // Positioning
    tp: { css: 'top', isNumericFlexible: true, group: 'Positioning' },
    lt: { css: 'left', isNumericFlexible: true, group: 'Positioning' },
    ri: { css: 'right', isNumericFlexible: true, group: 'Positioning' },
    bt: { css: 'bottom', isNumericFlexible: true, group: 'Positioning' },
    ps: { css: 'position', isKeyword: true, group: 'Positioning' },

    // Typography
    fs: { css: 'fontSize', isNumericFlexible: true, group: 'Typography' },
    lh: { css: 'lineHeight', isNumericFlexible: true, group: 'Typography' },
    fw: { css: 'fontWeight', isInteger: true, group: 'Typography' },
    ls: { css: 'letterSpacing', isNumericFlexible: true, group: 'Typography' },
    ta: { css: 'textAlign', isKeyword: true, group: 'Typography' },

    // Effects
    op: { css: 'opacity', isInteger: true, isOpacity: true, group: 'Effects' },
    zi: { css: 'zIndex', isInteger: true, group: 'Effects' },

    // Layout
    dp: { css: 'display', isKeyword: true, group: 'Layout' },
    ov: { css: 'overflow', isKeyword: true, group: 'Layout' },
    cu: { css: 'cursor', isKeyword: true, group: 'Layout' },
    fl: { css: 'float', isKeyword: true, group: 'Layout' },
    cl: { css: 'clear', isKeyword: true, group: 'Layout' },
    us: { css: 'userSelect', isKeyword: true, group: 'Layout' },

    // Flexbox
    fd: { css: 'flexDirection', isKeyword: true, group: 'Flexbox' },
    jc: { css: 'justifyContent', isKeyword: true, group: 'Flexbox' },
    ai: { css: 'alignItems', isKeyword: true, group: 'Flexbox' },
    ac: { css: 'alignContent', isKeyword: true, group: 'Flexbox' },
    fx: { css: 'flexWrap', isKeyword: true, group: 'Flexbox' },
    fg: { css: 'flexGrow', isDecimalFixed: true, group: 'Flexbox' },
    fk: { css: 'flexShrink', isDecimalFixed: true, group: 'Flexbox' },
    fb: { css: 'flexBasis', isNumericFlexible: true, group: 'Flexbox' },
    or: { css: 'order', isInteger: true, group: 'Flexbox' },

    // Grid
    gc: { css: 'gridTemplateColumns', isGridTrackList: true, group: 'Grid' },
    gr: { css: 'gridTemplateRows', isGridTrackList: true, group: 'Grid' },
    gl: { css: 'gridColumn', isRange: true, group: 'Grid' },
    gw: { css: 'gridRow', isRange: true, group: 'Grid' },
    gp: { css: 'gap', isNumericFlexible: true, group: 'Spacing' },

    // Transform
    rt: { css: 'rotate', isTransform: 'rotate', group: 'Transform' },
    sc: { css: 'scale', isTransform: 'scale', group: 'Transform' },

    // Other
    ar: { css: 'aspectRatio', isRange: true, group: 'Layout' },
    bz: { css: 'backgroundSize', isKeyword: true, group: 'Background' },
  };

  /**
   * Keyword mappings for CSS properties that accept specific keywords.
   *
   * Maps CSS property names to arrays of valid keyword values that can
   * be used in autocomplete suggestions.
   *
   * @type {Object.<string, string[]>}
   */
  const keywordMap = {
    display: ['none', 'block', 'inline', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline-grid', 'table', 'table-cell'],
    position: ['static', 'relative', 'absolute', 'fixed', 'sticky'],
    textAlign: ['left', 'center', 'right', 'justify', 'start', 'end'],
    overflow: ['visible', 'hidden', 'auto', 'scroll', 'clip'],
    cursor: ['auto', 'default', 'pointer', 'move', 'text', 'wait', 'help', 'progress', 'not-allowed', 'grab', 'grabbing'],
    borderStyle: ['none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset'],
    backgroundSize: ['auto', 'cover', 'contain'],
    flexDirection: ['row', 'row-reverse', 'column', 'column-reverse'],
    justifyContent: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'space-evenly', 'start', 'end'],
    alignItems: ['flex-start', 'flex-end', 'center', 'baseline', 'stretch', 'start', 'end'],
    alignContent: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around', 'stretch', 'start', 'end'],
    flexWrap: ['nowrap', 'wrap', 'wrap-reverse'],
    float: ['left', 'right', 'none', 'inline-start', 'inline-end'],
    clear: ['left', 'right', 'both', 'none', 'inline-start', 'inline-end'],
    userSelect: ['none', 'auto', 'text', 'all', 'contain'],
  };

  /**
   * Common color values for autocomplete suggestions.
   *
   * Provides a curated list of commonly used colors with friendly names
   * and hex values (without # prefix) for UtiliKit color utilities.
   *
   * @type {Array<{name: string, value: string}>}
   */
  const commonColors = [
    { name: 'Black', value: '000000' },
    { name: 'White', value: 'ffffff' },
    { name: 'Gray 100', value: 'f8f9fa' },
    { name: 'Gray 200', value: 'e9ecef' },
    { name: 'Gray 300', value: 'dee2e6' },
    { name: 'Gray 400', value: 'ced4da' },
    { name: 'Gray 500', value: 'adb5bd' },
    { name: 'Gray 600', value: '6c757d' },
    { name: 'Gray 700', value: '495057' },
    { name: 'Gray 800', value: '343a40' },
    { name: 'Gray 900', value: '212529' },
    { name: 'Blue', value: '007bff' },
    { name: 'Green', value: '28a745' },
    { name: 'Red', value: 'dc3545' },
    { name: 'Yellow', value: 'ffc107' },
    { name: 'Cyan', value: '17a2b8' },
    { name: 'Purple', value: '6f42c1' },
    { name: 'Orange', value: 'fd7e14' },
  ];

  /**
   * Generates autocomplete suggestions for a specific prefix and rule.
   *
   * Creates appropriate suggestions based on the rule type, including
   * numeric values, keywords, colors, directional variants, and more.
   *
   * @param {string} prefix
   *   The UtiliKit prefix (e.g., 'pd', 'mg', 'bg').
   * @param {Object} rule
   *   The rule configuration object from utilikitRules.
   *
   * @returns {string[]}
   *   Array of complete UtiliKit class suggestions.
   */
  function generateSuggestionsForPrefix(prefix, rule) {
    const suggestions = [];

    if (rule.isNumericFlexible) {
      // Common spacing values
      const values = [0, 2, 4, 6, 8, 10, 12, 14, 16, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128];
      values.forEach(v => suggestions.push(`uk-${prefix}--${v}`));

      // Percentage values
      ['10pr', '20pr', '25pr', '30pr', '33pr', '40pr', '50pr', '60pr', '66pr', '70pr', '75pr', '80pr', '90pr', '100pr'].forEach(v => {
        suggestions.push(`uk-${prefix}--${v}`);
      });

      // Viewport units for sizing properties
      if (['wd', 'ht', 'xw', 'xh', 'nw', 'nh', 'fs'].includes(prefix)) {
        ['25vh', '50vh', '75vh', '100vh', '25vw', '50vw', '75vw', '100vw'].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      }

      // Rem/em values
      ['0d25rem', '0d5rem', '0d75rem', '1rem', '1d25rem', '1d5rem', '2rem', '2d5rem', '3rem', '4rem', '5rem'].forEach(v => {
        suggestions.push(`uk-${prefix}--${v}`);
      });

      // If it supports sides, add directional examples
      if (rule.sides) {
        ['t', 'r', 'b', 'l'].forEach(dir => {
          [0, 8, 16, 24, 32].forEach(v => {
            suggestions.push(`uk-${prefix}--${dir}-${v}`);
          });
        });
        // Two-value shorthand
        suggestions.push(`uk-${prefix}--16-32`, `uk-${prefix}--20-40`, `uk-${prefix}--24-48`);
        // Four-value shorthand
        suggestions.push(`uk-${prefix}--16-24-32-40`);
      }

      // If it allows auto
      if (rule.allowAuto) {
        suggestions.push(`uk-${prefix}--auto`);
        if (rule.sides) {
          ['t', 'r', 'b', 'l'].forEach(dir => {
            suggestions.push(`uk-${prefix}--${dir}-auto`);
          });
          suggestions.push(`uk-${prefix}--0-auto`);
        }
      }
    }

    else if (rule.isKeyword) {
      const keywords = keywordMap[rule.css] || [];
      keywords.forEach(keyword => {
        suggestions.push(`uk-${prefix}--${keyword}`);
      });
    }

    else if (rule.isColor) {
      commonColors.forEach(color => {
        suggestions.push(`uk-${prefix}--${color.value}`);
        // Add common opacity variants
        ['25', '50', '75', '90'].forEach(opacity => {
          suggestions.push(`uk-${prefix}--${color.value}-${opacity}`);
        });
      });
    }

    else if (rule.isInteger) {
      if (rule.isOpacity) {
        [0, 5, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 95, 100].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      } else if (prefix === 'fw') {
        [100, 200, 300, 400, 500, 600, 700, 800, 900].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      } else if (prefix === 'zi') {
        [-1, 0, 1, 10, 20, 30, 40, 50, 100, 999, 9999].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      } else if (prefix === 'or') {
        [-1, 0, 1, 2, 3, 4, 5].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      }
    }

    else if (rule.isDecimalFixed) {
      [0, '0d5', 1, '1d5', 2, '2d5', 3].forEach(v => {
        suggestions.push(`uk-${prefix}--${v}`);
      });
    }

    else if (rule.isTransform) {
      if (rule.isTransform === 'rotate') {
        [-180, -90, -45, 0, 45, 90, 180, 270, 360].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      } else if (rule.isTransform === 'scale') {
        [0, 50, 75, 90, 95, 100, 105, 110, 125, 150, 200].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      }
    }

    else if (rule.isGridTrackList) {
      const examples = [
        '1fr',
        '2fr',
        '1fr-1fr',
        '1fr-2fr',
        '2fr-1fr',
        '1fr-1fr-1fr',
        'repeat-2-1fr',
        'repeat-3-1fr',
        'repeat-4-1fr',
        'repeat-5-1fr',
        'repeat-6-1fr',
        'repeat-12-1fr',
        'repeat-auto-fit-minmax-200px-1fr',
        'repeat-auto-fit-minmax-250px-1fr',
        'repeat-auto-fit-minmax-300px-1fr',
        'repeat-auto-fill-minmax-200px-1fr',
        'repeat-auto-fill-minmax-250px-1fr',
        '200px-1fr',
        '250px-1fr',
        '300px-1fr',
        '1fr-200px',
        '1fr-300px',
        'minmax-200px-1fr',
        'minmax-0-1fr',
      ];
      examples.forEach(v => suggestions.push(`uk-${prefix}--${v}`));
    }

    else if (rule.isRange) {
      if (prefix === 'ar') {
        ['1-1', '3-2', '4-3', '5-4', '16-9', '21-9', '2-1', '3-1'].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      } else {
        // Grid lines
        ['1-2', '1-3', '1-4', '1-5', '2-3', '2-4', '2-5', '3-4', '3-5', 'span-1', 'span-2', 'span-3', 'span-4'].forEach(v => {
          suggestions.push(`uk-${prefix}--${v}`);
        });
      }
    }

    return suggestions;
  }

  /**
   * Converts a UtiliKit class name to a human-readable property name.
   *
   * Extracts the prefix from a class name and converts the corresponding
   * CSS property to a readable format for display in autocomplete.
   *
   * @param {string} className
   *   The UtiliKit class name to analyze (e.g., 'uk-pd--20').
   *
   * @returns {string}
   *   Human-readable property name (e.g., 'padding').
   */
  function getPropertyName(className) {
    const match = className.match(/^uk-([a-z]+)--/);
    if (!match) return '';

    const prefix = match[1];
    const rule = utilikitRules[prefix];

    if (!rule) return prefix;

    // Convert camelCase to readable format
    const cssProperty = rule.css || prefix;
    return cssProperty
      .replace(/([A-Z])/g, ' $1')
      .toLowerCase()
      .trim();
  }

  /**
   * Updates visual selection highlighting in autocomplete dropdown.
   *
   * Manages the visual state of autocomplete items, highlighting the
   * currently selected item and ensuring it's visible in the scroll area.
   *
   * @param {NodeList} items
   *   The autocomplete item elements to update.
   * @param {number} selectedIndex
   *   The index of the currently selected item (-1 for no selection).
   */
  function updateAutocompleteSelection(items, selectedIndex) {
    items.forEach((item, index) => {
      item.classList.toggle('selected', index === selectedIndex);
      if (index === selectedIndex) {
        // Ensure selected item is visible
        item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      }
    });
  }

  /**
   * Initializes autocomplete functionality for the class input field.
   *
   * Sets up input monitoring, suggestion generation, keyboard navigation,
   * and selection handling for UtiliKit class autocomplete.
   *
   * @param {Element} wrapper
   *   The playground wrapper element containing autocomplete controls.
   */
  function initAutocomplete(wrapper) {
    const classInput = document.getElementById('utilikit-class-input');
    const autocomplete = document.getElementById('class-autocomplete');

    if (!classInput || !autocomplete) return;

    let selectedIndex = -1;
    let currentSuggestions = [];

    // Input event handler
    classInput.addEventListener('input', function(e) {
      const value = e.target.value;
      const lastClass = value.split(' ').pop();

      // Only show suggestions if typing a utility class
      if (lastClass.startsWith('uk-') && lastClass.length >= 3) {
        let allSuggestions = [];

        // Extract the prefix being typed
        const match = lastClass.match(/^uk-([a-z]+)/);
        if (match) {
          const inputPrefix = match[1];

          // Find all matching rules
          Object.entries(utilikitRules).forEach(([rulePrefix, rule]) => {
            if (rulePrefix.startsWith(inputPrefix)) {
              const suggestions = generateSuggestionsForPrefix(rulePrefix, rule);
              // Filter to only show suggestions that match what's typed
              const filtered = suggestions.filter(s => s.startsWith(lastClass));
              allSuggestions.push(...filtered);
            }
          });
        }

        // Remove duplicates and limit results
        currentSuggestions = [...new Set(allSuggestions)].slice(0, 20);

        if (currentSuggestions.length > 0) {
          selectedIndex = -1;

          // Build autocomplete HTML
          autocomplete.innerHTML = currentSuggestions.map((sug, index) => {
            const propertyName = getPropertyName(sug);
            return `
              <div class="autocomplete-item" data-index="${index}" data-value="${sug}">
                <span class="autocomplete-class">${sug}</span>
                <span class="autocomplete-property">${propertyName}</span>
              </div>
            `;
          }).join('');

          autocomplete.style.display = 'block';

          // Attach click handlers to new items
          autocomplete.querySelectorAll('.autocomplete-item').forEach(item => {
            item.addEventListener('click', function() {
              const suggestion = this.dataset.value;
              const classes = classInput.value.split(' ');
              classes[classes.length - 1] = suggestion;
              classInput.value = classes.join(' ');
              autocomplete.style.display = 'none';
              selectedIndex = -1;
              classInput.focus();

              // Trigger form submit to update preview
              const form = document.getElementById('utilikit-preview-form');
              if (form) {
                form.dispatchEvent(new Event('submit'));
              }
            });
          });
        } else {
          autocomplete.style.display = 'none';
          currentSuggestions = [];
        }
      } else {
        autocomplete.style.display = 'none';
        currentSuggestions = [];
      }
    });

    // Keyboard navigation
    classInput.addEventListener('keydown', function(e) {
      const items = autocomplete.querySelectorAll('.autocomplete-item');
      const isVisible = autocomplete.style.display !== 'none';

      if (!isVisible || items.length === 0) return;

      switch(e.key) {
        case 'ArrowDown':
          e.preventDefault();
          selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
          updateAutocompleteSelection(items, selectedIndex);
          break;

        case 'ArrowUp':
          e.preventDefault();
          selectedIndex = Math.max(selectedIndex - 1, -1);
          updateAutocompleteSelection(items, selectedIndex);
          break;

        case 'Enter':
          if (selectedIndex >= 0 && items[selectedIndex]) {
            e.preventDefault();
            items[selectedIndex].click();
          }
          break;

        case 'Tab':
          if (selectedIndex >= 0 && items[selectedIndex]) {
            e.preventDefault();
            items[selectedIndex].click();
          } else if (currentSuggestions.length === 1) {
            e.preventDefault();
            items[0].click();
          }
          break;

        case 'Escape':
          e.preventDefault();
          autocomplete.style.display = 'none';
          selectedIndex = -1;
          break;
      }
    });

    // Hide autocomplete when clicking outside
    classInput.addEventListener('blur', function() {
      // Delay to allow click events on autocomplete items
      setTimeout(() => {
        autocomplete.style.display = 'none';
        selectedIndex = -1;
        currentSuggestions = [];
      }, 200);
    });

    // Focus management
    classInput.addEventListener('focus', function() {
      // Re-trigger suggestions if input has a partial class
      if (this.value) {
        this.dispatchEvent(new Event('input'));
      }
    });
  }

  /**
   * UtiliKit Playground Autocomplete Drupal behavior.
   *
   * Initializes intelligent autocomplete functionality for UtiliKit
   * utility class suggestions in the playground interface.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Attaches autocomplete functionality to playground wrapper elements.
   */
  Drupal.behaviors.utilikitPlaygroundAutocomplete = {
    attach: function(context, settings) {
      once('utilikitPlaygroundAutocomplete', '.utilikit-playground-wrapper', context).forEach(wrapper => {
        initAutocomplete(wrapper);
      });
    }
  };

})(Drupal, once);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc