K-panel styling in custom vue.js (custom plugin)

Hi,

I’ve created a custom field-picker plugin. I’d like to stay as close to kirby cms native styling as possible so I’d like to use the k-table kind of styling in the template section of my vue.js to present my fields in a table.

Seemingly just adding “k-table” like syntax isn’t enough to get the table styled as a default kirby table. Since whenever I wrap my table in

the sole table dissapears… it is however present in the page source… when I remove the k-table class the table show up again.

Did I forget something?

My current template part in my vue.js:

<template>
  <k-field :label="label" :help="help" :disabled="disabled" :required="required">
    <div v-if="fields.length > 0" class="field-selector-simple">
      <div class="k-table">
      <table>
        <thead>
          <tr>
            <th></th>
            <th data-align="left">Field</th>
            <th data-align="left">Type</th>
            <th data-align="left">Party</th>
            <th data-align="left">Category</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="field in fields" :key="field.id">
            <td>
              <k-button @click="addField(field)"
                :title="`Add ${field.label || field.title} to view`" icon="angle-left">
              </k-button>
              
            </td>
            <td>
              <strong>{{ field.label || field.title }}</strong>
            </td>
            <td>
              <span>
                {{ field.type }}
              </span>
            </td>
            <td>
              <span :style="`background: ${field.party === 1 ? '#3498db' : '#27ae60'}; color: white; padding: 2px 6px; border-radius: 3px; font-size: 12px;`">
                {{ field.party_label }}
              </span>
            </td>
            <td>
              {{ field.category }}
            </td>
          </tr>
        </tbody>
        </table>
      </div>
    </div>
    
    <div v-else-if="!loading" style="text-align: center; padding: 2rem; color: #666; font-style: italic;">
      <p>Select a relationship schema to see available fields</p>
    </div>
    
    <div v-if="loading" style="text-align: center; padding: 2rem; color: #666;">
      <p>Loading fields...</p>
    </div>
  </k-field>
</template>

If the table is present in the source, you should be able to inspect the styling in dev tools to see why it is hidden?

1 Like

Thanks Sonja,

Did that again and found out that with the k-table also a k-grid css class is “forced” in the panel layout… at least i do not include a .k-grid class manually in my vue.js template part…

When toggling display: grid to “off” the k-table appears but… in one column layout such that all components are stacked vertically.

Any Idea how to circumvent this .k-grid behavior?

.k-grid {
    --columns: 12;
    --grid-inline-gap: 0;
    --grid-block-gap: 0;
    /* display: grid; */
    align-items: start;
    grid-column-gap: var(--grid-inline-gap);
    grid-row-gap: var(--grid-block-gap);
}

I’ll experiment some more with my yml layout.

thanks in advance

I guess that happens because the table is wrapped inside the k-field component?

1 Like

Thanks Sonja,

That’s probably the case I’ve tried some things like basing the custom plugin on another component like section and re-define css classes in the vue styling block but as soon as I use k-table the display: grid property gets inherited and the table visually vanishes in the panel while still existing. I’ve managed to remove the k-table object and replace it with vanilla html table with kirby-look-alike css. For me this is currently acceptable.

To be complete my full vue.js file below.

Thanks for your support Sonja.

<template>
  <k-field :label="label" :help="help" :disabled="disabled" :required="required">
    <div v-if="fields.length > 0" class="field-selector-simple">
      <div class="kirby-native-table">
        <table>
          <thead>
            <tr>
              <th></th>
              <th data-align="left">Field</th>
              <th data-align="left">Type</th>
              <th data-align="left">Party</th>
              <th data-align="left">Category</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="field in fields" :key="field.id">
              <td>
                <k-button @click="addField(field)"
                  :title="`Add ${field.label || field.title} to view`" icon="angle-left">
                </k-button>
              </td>
              <td>
                <strong>{{ field.label || field.title }}</strong>
              </td>
              <td>
                <span>
                  {{ field.type }}
                </span>
              </td>
              <td>
                <span :style="`background: ${field.party === 1 ? '#3498db' : '#27ae60'}; color: white; padding: 2px 6px; border-radius: 3px; font-size: 12px;`">
                  {{ field.party_label }}
                </span>
              </td>
              <td>
                {{ field.category }}
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
    
    <div v-else-if="!loading" style="text-align: center; padding: 2rem; color: #666; font-style: italic;">
      <p>Select a relationship schema to see available fields</p>
    </div>
    
    <div v-if="loading" style="text-align: center; padding: 2rem; color: #666;">
      <p>Loading fields...</p>
    </div>
  </k-field>
</template>

<script>
export default {
  props: {
    label: String,
    help: String,
    disabled: Boolean,
    required: Boolean,
    value: [String, Array, Object],
    relationship: {
      type: String,
      default: ''
    },
    filter: {
      type: String,
      default: 'relation_fields'
    },
    search: {
      type: String,
      default: ''
    }
  },
  data() {
    return {
      fields: [],
      loading: false,
      lastKnownRelationship: null,
      relationshipCheckInterval: null
    };
  },
  created() {
    this.loadFields();
  },
  methods: {
    async getRelationshipFromPage() {
      try {
        // Method 1: Check direct parent
        if (this.$parent?.value?.relationship_reference) {
          const parentValue = this.$parent.value.relationship_reference;
          let pageId = null;
          if (Array.isArray(parentValue) && parentValue.length > 0) {
            const firstItem = parentValue[0];
            try {
              pageId = firstItem.id || firstItem.uuid || firstItem.link || firstItem.url || firstItem;
            } catch (error) {
              pageId = firstItem;
            }
          } else if (typeof parentValue === 'object' && parentValue !== null) {
            try {
              pageId = parentValue.id || parentValue.uuid || parentValue.link || parentValue.url || parentValue;
            } catch (error) {
              pageId = parentValue;
            }
          } else {
            pageId = parentValue;
          }
          return pageId;
        }

        // Method 2: Traverse component tree to find form
        let current = this.$parent;
        while (current) {
          if (current.value && current.value.relationship_reference) {
            const relationshipValue = current.value.relationship_reference;
            let pageId = null;
            if (Array.isArray(relationshipValue) && relationshipValue.length > 0) {
              const firstItem = relationshipValue[0];
              try {
                pageId = firstItem.id || firstItem.uuid || firstItem.link || firstItem.url || firstItem;
              } catch (error) {
                pageId = firstItem;
              }
            } else if (typeof relationshipValue === 'object' && relationshipValue !== null) {
              try {
                pageId = relationshipValue.id || relationshipValue.uuid || relationshipValue.link || relationshipValue.url || relationshipValue;
              } catch (error) {
                pageId = relationshipValue;
              }
            } else {
              pageId = relationshipValue;
            }
            return pageId;
          }
          current = current.$parent;
        }

        // Method 3: Try to access form data through $refs or global state
        if (this.$root && this.$root.$children) {
          const formComponent = this.$root.$children.find(child => child.value && child.value.relationship_reference);
          if (formComponent) {
            const relationshipValue = formComponent.value.relationship_reference;
            let pageId = null;
            if (Array.isArray(relationshipValue) && relationshipValue.length > 0) {
              const firstItem = relationshipValue[0];
              try {
                pageId = firstItem.id || firstItem.uuid || firstItem.link || firstItem.url || firstItem;
              } catch (error) {
                pageId = firstItem;
              }
            } else if (typeof relationshipValue === 'object' && relationshipValue !== null) {
              try {
                pageId = relationshipValue.id || relationshipValue.uuid || relationshipValue.link || relationshipValue.url || relationshipValue;
              } catch (error) {
                pageId = relationshipValue;
              }
            } else {
              pageId = relationshipValue;
            }
            return pageId;
          }
        }

      } catch (error) {
        // Silent error handling
      }
      
      return null;
    },

    async loadFields() {
      this.loading = true;
      try {
        // Get relationship from prop or try to get it dynamically
        let relationshipId = this.relationship || await this.getRelationshipFromPage();
        
        if (!relationshipId) {
          this.fields = [];
          this.loading = false;
          return;
        }

        const filter = this.filter;
        const apiUrl = `view-builder/field-library/${encodeURIComponent(relationshipId)}/${encodeURIComponent(filter)}`;

        // Call the API to get field data
        const response = await this.$api.get(apiUrl);

        if (response && response.fields) {
          this.fields = response.fields;
        }
      } catch (error) {
        this.fields = [];
      } finally {
        this.loading = false;
      }
    },

    addField(field) {
      // Get current form data from parent
      let current = this.$parent;
      let formData = null;

      while (current && !formData) {
        if (current.value) {
          formData = current.value;
          break;
        }
        current = current.$parent;
      }

      if (!formData) {
        return;
      }

      // Determine which structure field to update based on current context
      const currentContext = formData.current_view_context || 'party_1';
      const targetField = currentContext === 'party_1' ? 'party_1_view_fields' : 'party_2_view_fields';

      // Get current structure value
      const currentStructure = formData[targetField] || [];

      // Create new field entry
      const newFieldEntry = {
        field_reference: field.id,
        field_label: field.title,
        field_type: field.type
      };

      // Add to structure
      const updatedStructure = [...currentStructure, newFieldEntry];

      // Trigger form update through the parent component
      if (current && current.$emit) {
        current.$emit('input', {
          ...formData,
          [targetField]: updatedStructure
        });
      }
    },

    async checkForRelationshipChanges() {
      const currentRelationship = await this.getRelationshipFromPage();
      if (currentRelationship && currentRelationship !== this.lastKnownRelationship) {
        this.lastKnownRelationship = currentRelationship;
        this.loadFields();
      }
    }
  },
  watch: {
    relationship: {
      handler() {
        this.loadFields();
      }
    },
    filter: {
      handler() {
        this.loadFields();
      }
    }
  },
  mounted() {
    // Set up polling to check for relationship changes
    this.relationshipCheckInterval = setInterval(() => {
      this.checkForRelationshipChanges();
    }, 1000);
  },
  beforeDestroy() {
    if (this.relationshipCheckInterval) {
      clearInterval(this.relationshipCheckInterval);
    }
  },
}
</script>

<style scoped>
/* Scoped styles to prevent leakage */
.field-selector-simple {
  width: 100%;
  overflow: visible;
}

.kirby-native-table {
  border: 1px solid var(--color-border);
  border-radius: var(--rounded);
  overflow-x: auto;
  overflow-y: hidden;
  background: var(--color-white);
  width: 100%;
  max-width: 100%;
  display: block;
  scroll-behavior: smooth;
  scrollbar-width: thin;
  scrollbar-color: var(--color-gray-400) var(--color-gray-100);
  position: relative;
}

.kirby-native-table::-webkit-scrollbar {
  height: 8px;
}

.kirby-native-table::-webkit-scrollbar-track {
  background: var(--color-gray-100);
  border-radius: 4px;
}

.kirby-native-table::-webkit-scrollbar-thumb {
  background: var(--color-gray-400);
  border-radius: 4px;
}

.kirby-native-table::-webkit-scrollbar-thumb:hover {
  background: var(--color-gray-500);
}

.kirby-native-table table {
  min-width: 700px;
  width: 100%;
  border-collapse: collapse;
  border-spacing: 0;
  font-size: var(--text-sm);
  background: transparent;
  table-layout: auto; /* Changed from fixed to allow dynamic column shifting */
}

.kirby-native-table th {
  background: var(--color-gray-100);
  border-bottom: 1px solid var(--color-border);
  padding: var(--spacing-1) 0;
  text-align: left;
  font-weight: var(--font-medium);
  color: var(--color-gray-700);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  white-space: nowrap;
  line-height: 1.2;
  vertical-align: middle; /* Ensure consistent vertical alignment */
}

.kirby-native-table td {
  padding: var(--spacing-1) 0;
  border-bottom: 1px solid var(--color-border);
  vertical-align: middle; /* Ensure consistent vertical alignment */
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 1.2;
  font-size: 0.8125rem;
  box-sizing: border-box;
}

.kirby-native-table td:first-child {
  text-overflow: clip;
  white-space: normal;
  overflow: visible;
}

.kirby-native-table td:nth-child(2) {
  max-width: 100px; /* Constrain to ~16 characters */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* Removed display: block to maintain table-cell behavior */
}

.kirby-native-table th:nth-child(2) {
  max-width: 100px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* Removed display: block to maintain table-cell behavior */
}

.kirby-native-table td::before,
.kirby-native-table td::after {
  content: none;
}

.kirby-native-table tr:last-child td {
  border-bottom: none;
}

.kirby-native-table tr:hover {
  background: var(--color-gray-50);
}

/* Column sizing */
.kirby-native-table th:first-child,
.kirby-native-table td:first-child {
  width: 2rem;
  text-align: center;
}

.kirby-native-table th:nth-child(2),
.kirby-native-table td:nth-child(2) {
  max-width: 30px; /* Constrain width */
  min-width: 0; /* Allow shrinking */
}

.kirby-native-table th:nth-child(3),
.kirby-native-table td:nth-child(3) {
  width: 10%;
  min-width: 0; /* Allow flexible resizing */
}

.kirby-native-table th:nth-child(4),
.kirby-native-table td:nth-child(4) {
  width: 12%;
  min-width: 0; /* Allow flexible resizing */
}

.kirby-native-table th:nth-child(5),
.kirby-native-table td:nth-child(5) {
  width: auto; /* Allow to take remaining space */
  min-width: 0;
}

/* Dark mode support */
@media (prefers-color-scheme: dark) {
  .kirby-native-table {
    background: var(--color-back);
    border-color: var(--color-border);
  }
  
  .kirby-native-table th {
    background: var(--color-gray-900);
    color: var(--color-gray-400);
  }
  
  .kirby-native-table tr:hover {
    background: var(--color-gray-800);
  }
}

.k-table-text {
  color: var(--color-gray-700);
  font-size: 0.875rem;
}
</style>