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

Allow to customize CSS classes of individual table headers and rows #366

Closed
goodpixels opened this issue Jun 29, 2023 · 7 comments
Closed
Labels
enhancement New feature or request

Comments

@goodpixels
Copy link

Is your feature request related to a problem? Please describe.

I've got several columns in the table and I need to set a fixed width for a specific column only.

Describe the solution you'd like

A) Add a new classNames attribute to the columns prop object
B) Expose the cell class names in the [key]-header / [key-]data slot so I can define the cell container in the markup to my liking, ie., ie.:

<template #id-header="{ column, classNames }">
<div :class="[classNames, 'w-[200px]']">{{ column.label }}</div>
</template>

Describe alternatives you've considered

For the time being, I had to resort to using scoped CSS.

Screenshot 2023-06-29 at 13 55 14
@goodpixels goodpixels added the enhancement New feature or request label Jun 29, 2023
@goodpixels goodpixels changed the title Allow to customize CSS classes of table headers and rows Allow to customize CSS classes of tindividual able headers and rows Jun 29, 2023
@goodpixels goodpixels changed the title Allow to customize CSS classes of tindividual able headers and rows Allow to customize CSS classes of individual table headers and rows Jun 29, 2023
@goodpixels
Copy link
Author

Apologies, as I don't have the time to create a fork and do a Pull Request, but this is a simple change, I've just made it locally:

src/runtime/components/data/Table.vue, changed lines 10 and 99

<template>
  <div :class="ui.wrapper">
    <table :class="[ui.base, ui.divide]">
      <thead :class="ui.thead">
        <tr :class="ui.tr.base">
          <th v-if="modelValue" scope="col" class="ps-4">
            <UCheckbox :checked="indeterminate || selected.length === rows.length" :indeterminate="indeterminate" @change="selected = $event.target.checked ? rows : []" />
          </th>

          <th v-for="(column, index) in columns" :key="index" scope="col" :class="[ui.th.base, ui.th.padding, ui.th.color, ui.th.font, ui.th.size, column.classNames]">
            <slot :name="`${column.key}-header`" :column="column" :sort="sort" :on-sort="onSort">
              <UButton
                v-if="column.sortable"
                v-bind="{ ...ui.default.sortButton, ...sortButton }"
                :icon="(!sort.column || sort.column !== column.key) ? (sortButton.icon || ui.default.sortButton.icon) : sort.direction === 'asc' ? sortAscIcon : sortDescIcon"
                :label="column[columnAttribute]"
                @click="onSort(column)"
              />
              <span v-else>{{ column[columnAttribute] }}</span>
            </slot>
          </th>
        </tr>
      </thead>
      <tbody :class="ui.tbody">
        <tr v-for="(row, index) in rows" :key="index" :class="[ui.tr.base, isSelected(row) && ui.tr.selected]">
          <td v-if="modelValue" class="ps-4">
            <UCheckbox v-model="selected" :value="row" />
          </td>

          <td v-for="(column, subIndex) in columns" :key="subIndex" :class="[ui.td.base, ui.td.padding, ui.td.color, ui.td.font, ui.td.size]">
            <slot :name="`${column.key}-data`" :column="column" :row="row" :index="index">
              {{ row[column.key] }}
            </slot>
          </td>
        </tr>

        <tr v-if="loadingState && loading">
          <td :colspan="columns.length + (modelValue ? 1 : 0)">
            <slot name="loading-state">
              <div :class="ui.loadingState.wrapper">
                <UIcon v-if="loadingState.icon" :name="loadingState.icon" :class="ui.loadingState.icon" aria-hidden="true" />
                <p :class="ui.loadingState.label">
                  {{ loadingState.label }}
                </p>
              </div>
            </slot>
          </td>
        </tr>

        <tr v-else-if="emptyState && !rows.length">
          <td :colspan="columns.length + (modelValue ? 1 : 0)">
            <slot name="empty-state">
              <div :class="ui.emptyState.wrapper">
                <UIcon v-if="emptyState.icon" :name="emptyState.icon" :class="ui.emptyState.icon" aria-hidden="true" />
                <p :class="ui.emptyState.label">
                  {{ emptyState.label }}
                </p>
              </div>
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script lang="ts">
import { ref, computed, defineComponent, toRaw } from 'vue'
import type { PropType } from 'vue'
import { capitalize, orderBy } from 'lodash-es'
import { defu } from 'defu'
import type { Button } from '../../types/button'
import { useAppConfig } from '#imports'
// TODO: Remove
// @ts-expect-error
import appConfig from '#build/app.config'

// const appConfig = useAppConfig()

function defaultComparator<T> (a: T, z: T): boolean {
  return a === z
}

export default defineComponent({
  props: {
    modelValue: {
      type: Array,
      default: null
    },
    by: {
      type: [String, Function],
      default: () => defaultComparator
    },
    rows: {
      type: Array as PropType<{ [key: string]: any }[]>,
      default: () => []
    },
    columns: {
      type: Array as PropType<{ key: string, classNames?: string, sortable?: boolean, [key: string]: any }[]>,
      default: null
    },
    columnAttribute: {
      type: String,
      default: 'label'
    },
    sort: {
      type: Object as PropType<{ column: string, direction: 'asc' | 'desc' }>,
      default: () => ({})
    },
    sortButton: {
      type: Object as PropType<Partial<Button>>,
      default: () => appConfig.ui.table.default.sortButton
    },
    sortAscIcon: {
      type: String,
      default: () => appConfig.ui.table.default.sortAscIcon
    },
    sortDescIcon: {
      type: String,
      default: () => appConfig.ui.table.default.sortDescIcon
    },
    loading: {
      type: Boolean,
      default: false
    },
    loadingState: {
      type: Object as PropType<{ icon: string, label: string }>,
      default: () => appConfig.ui.table.default.loadingState
    },
    emptyState: {
      type: Object as PropType<{ icon: string, label: string }>,
      default: () => appConfig.ui.table.default.emptyState
    },
    ui: {
      type: Object as PropType<Partial<typeof appConfig.ui.table>>,
      default: () => appConfig.ui.table
    }
  },
  emits: ['update:modelValue'],
  setup (props, { emit }) {
    // TODO: Remove
    const appConfig = useAppConfig()

    const ui = computed<Partial<typeof appConfig.ui.table>>(() => defu({}, props.ui, appConfig.ui.table))

    const columns = computed(() => props.columns ?? Object.keys(props.rows[0] ?? {}).map((key) => ({ key, label: capitalize(key), sortable: false })))

    const sort = ref(defu({}, props.sort, { column: null, direction: 'asc' }))

    const rows = computed(() => {
      if (!sort.value?.column) {
        return props.rows
      }

      const { column, direction } = sort.value

      return orderBy(props.rows, column, direction)
    })

    const selected = computed({
      get () {
        return props.modelValue
      },
      set (value) {
        emit('update:modelValue', value)
      }
    })

    const indeterminate = computed(() => selected.value && selected.value.length > 0 && selected.value.length < props.rows.length)

    const emptyState = computed(() => ({ ...ui.value.default.emptyState, ...props.emptyState }))

    function compare (a: any, z: any) {
      if (typeof props.by === 'string') {
        const property = props.by as unknown as any
        return a?.[property] === z?.[property]
      }
      return props.by(a, z)
    }

    function isSelected (row) {
      if (!props.modelValue) {
        return false
      }

      return selected.value.some((item) => compare(toRaw(item), toRaw(row)))
    }

    function onSort (column) {
      if (sort.value.column === column.key) {
        const direction = !column.direction || column.direction === 'asc' ? 'desc' : 'asc'

        if (sort.value.direction === direction) {
          sort.value = defu({}, props.sort, { column: null, direction: 'asc' })
        } else {
          sort.value.direction = sort.value.direction === 'asc' ? 'desc' : 'asc'
        }
      } else {
        sort.value = { column: column.key, direction: column.direction || 'asc' }
      }
    }

    return {
      // eslint-disable-next-line vue/no-dupe-keys
      ui,
      // eslint-disable-next-line vue/no-dupe-keys
      sort,
      // eslint-disable-next-line vue/no-dupe-keys
      columns,
      // eslint-disable-next-line vue/no-dupe-keys
      rows,
      selected,
      indeterminate,
      // eslint-disable-next-line vue/no-dupe-keys
      emptyState,
      isSelected,
      onSort
    }
  }
})
</script>

Copy link
Member

Great idea! I'd go for class instead of classNames though.

@goodpixels
Copy link
Author

Thanks.

While we're chatting about this component, let me ask a question.

I would like to use my own components as table cells, ie. <CustomTHead> and <CustomTCell>. Right now the only way to do it, is on a per-column basis, using the column slots, which is highly repetitive. How can I achieve this without adding templates for each column and row slots?

Copy link
Member

What would be the use-case? With slots and through app.config.ts customization, you should be able to do whatever you want but I might be missing something.

@goodpixels
Copy link
Author

goodpixels commented Jun 29, 2023

The use case is actually quite common, I need to use the <Heading> and <Text> components (they're responsive) to render the values in the table. Re-declaring all the style combinations in app.config.ts doesn't seem like the approach I would like to take long-term :)

Copy link
Member

Would you mind opening a new issue for this?

@goodpixels
Copy link
Author

Done, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants