>(), {
scrollable: undefined,
stickyHeader: false,
height: undefined,
- debounceSearch: () => getDefault("table.debounceSearch"),
+ debounceSearch: () => getDefault("table.debounceSearch", 300),
checkable: false,
stickyCheckbox: false,
headerCheckable: true,
@@ -144,11 +145,6 @@ const emits = defineEmits<{
* @param value {number} updated currentPage prop
*/
"update:currentPage": [value: number];
- /**
- * is emitted each time the table data is processed into rows
- * @param value {TableRow[]} computed table rows
- */
- processed: [value: TableRow
[]];
/**
* on pagination page change event
* @param page {number} updated page
@@ -341,11 +337,13 @@ const emits = defineEmits<{
columndragover: [column: TableColumn, index: number, event: DragEvent];
}>();
+const slots = useSlots();
+
const { isMobile } = useMatchMedia(props.mobileBreakpoint);
const isMobileActive = computed(() => props.mobileCards && isMobile.value);
-const slotRef = useTemplateRef("slotElement");
+const slotsRef = useTemplateRef("slotsWrapper");
// provided data is a computed ref to enjure reactivity
const provideData = computed(() => ({
@@ -354,130 +352,159 @@ const provideData = computed(() => ({
/** provide functionalities and data to child item components */
const { childItems } = useProviderParent>({
- rootRef: slotRef,
+ rootRef: slotsRef,
data: provideData,
});
+// #region --- TABLE COLUMNS ---
+
/** all defined columns */
const tableColumns = computed[]>(() => {
- if (!childItems.value) return [];
- return childItems.value.map((column) => ({
- index: column.index,
- identifier: column.identifier,
- ...toValue(column.data!),
- thAttrsData: {},
- tdAttrsData: [],
- }));
+ if (!childItems.value.length) return [];
+ return childItems.value.map((columnItem) => {
+ const column = toValue(columnItem.data!);
+
+ // create additional th attrs data
+ let thAttrsData =
+ typeof props.thAttrs === "function" ? props.thAttrs(column) : {};
+ thAttrsData = Object.assign(thAttrsData, column.thAttrs);
+
+ // create additional td attrs data
+ const tdAttrsData = (props.data ?? []).map((data) => {
+ const tdAttrs =
+ typeof props.tdAttrs === "function"
+ ? props.tdAttrs(data, column)
+ : {};
+ return Object.assign(tdAttrs, column.tdAttrs);
+ });
+
+ return {
+ ...column,
+ value: column,
+ index: columnItem.index,
+ identifier: columnItem.identifier,
+ thAttrsData: thAttrsData,
+ tdAttrsData: tdAttrsData,
+ };
+ });
});
-// create a unique id sequence
-const { nextSequence } = useSequentialId();
-
-/** all defined data elements as an object map */
-const tableData = computed[]>(() =>
- useObjectMap(props.data, props.rowKey, nextSequence),
-);
-
-const tableRows = ref(tableData.value) as Ref[]>;
-
-/** recompute table rows when table data change */
-watch(tableData, () => processTableData());
+/** total columns count */
+const columnCount = computed(() => {
+ let i = tableColumns.value.length;
+ if (showDetailRowIcon.value) i++;
+ if (props.checkable) i++;
+ return i;
+});
-/**
- * Compute tableRows based on:
- * 1. Filter data if it's not backend-filtered.
- * 2. Sort data if it's not backend-sorted.
- * 3. Update internal value.
- */
-function processTableData(): void {
- // create new array to don't mutate the original data order
- let rows = [...tableData.value];
+/** aria-colindex start value for ths */
+const ariaColIndexStart = computed(() => {
+ let i = 1;
+ if (showDetailRowIcon.value) i++;
+ if (props.checkable && props.checkboxPosition === "left") i++;
+ return i;
+});
- // if not backend filtered, filter rows
- if (!props.backendFiltering) rows = filterRows(rows);
+/** check if table has subheadings */
+const hasSubheadings = computed(() => {
+ if (slots.subheading) return true;
+ return tableColumns.value.some((column) => !!column.subheading);
+});
- // if not backend sorted, sort rows
- if (!props.backendSorting) rows = sortByColumn(rows);
+/** check if table is scrollable */
+const isScrollable = computed(() => {
+ if (props.scrollable) return true;
+ return tableColumns.value.some((column) => column.sticky);
+});
- tableRows.value = rows;
- emits("processed", rows); // emit computed rows every time they the data get changed
-}
+// #endregion --- TABLE COLUMNS ---
-/** Shows total data. If backend paginated, use props total else use rows data length as pagination total */
-const tableTotal = computed(() =>
- props.backendPagination ? props.total : tableRows.value.length,
-);
+// #region --- TABLE ROWS ---
const tableCurrentPage = defineModel("currentPage", { default: 1 });
-/** visible rows based on current page */
-const visibleRows = computed[]>((): TableRow[] => {
- if (!props.paginated || props.backendPagination) return tableRows.value;
-
- const currentPage = tableCurrentPage.value;
- const perPage = Number(props.perPage);
+// recompute table rows visibility on page change or data change
+watch([tableCurrentPage, () => props.perPage, () => props.data], () =>
+ filterTableRows(),
+);
- if (tableRows.value.length <= perPage) return tableRows.value;
+// create a unique id sequence
+const { nextSequence } = useSequentialId();
- const start = (currentPage - 1) * perPage;
- const end = start + perPage;
- return tableRows.value.slice(start, end);
+/** all defined data elements as normalized options with a unique key*/
+const tableRows = computed[]>(() => {
+ if (!props.data) return [];
+ return props.data.map((value: T, idx: number) => ({
+ label: "row " + idx, // row display label
+ value: toValue(value), // normalizes wrapped ref values
+ index: idx, // row index
+ key:
+ // if no key is given and data is object, create unique row id for each row
+ String(
+ getValueByPath(
+ value,
+ props.rowKey,
+ nextSequence() as DeepType,
+ ),
+ ),
+ }));
});
-const visibleColumns = computed(() => {
- if (!tableColumns.value) return [];
- return tableColumns.value.filter(
- (column) => column.visible || column.visible === undefined,
- );
-});
+/** visible rows which are filtered by viability */
+const availableRows = computed[]>(() =>
+ tableRows.value.filter((o) => isOptionViable(o)),
+);
-/** process thAttrs & tdAttrs when row or columns got changed */
-watch([visibleRows, visibleColumns], () => {
- if (visibleColumns.value.length && visibleRows.value.length) {
- for (let i = 0; i < visibleColumns.value.length; i++) {
- const col = visibleColumns.value[i];
- // create additional th attrs data
- const thAttrs =
- typeof props.thAttrs === "function" ? props.thAttrs(col) : {};
- col.thAttrsData = Object.assign(thAttrs, col.thAttrs);
- // create additional td attrs data
- col.tdAttrsData = visibleRows.value.map((data) => {
- const tdAttrs =
- typeof props.tdAttrs === "function"
- ? props.tdAttrs(data.value, col)
- : {};
- return Object.assign(tdAttrs, col.tdAttrs);
- });
+/** applies visability filter of reactive tableRows */
+function filterTableRows(): void {
+ // calculate pagination information
+ const currentPage = tableCurrentPage.value;
+ const perPage = Number(props.perPage);
+ const pageStart = (currentPage - 1) * perPage;
+ const pageEnd = pageStart + perPage;
+
+ // update hidden state for each row
+ filterOptionsItems(tableRows, (row) => {
+ // if paginated not backend paginated, paginate row
+ if (props.paginated || !props.backendPagination) {
+ // if not only one page and not on active page
+ if (
+ tableRows.value.length > perPage &&
+ (row.index < pageStart || row.index > pageEnd)
+ )
+ // return row is invisible
+ return true;
}
- }
-});
-/** total column count based if it's checkable or expanded */
-const columnCount = computed(() => {
- let count = visibleColumns.value.length;
- count += props.checkable ? 1 : 0;
- count += props.detailed && props.showDetailIcon ? 1 : 0;
- return count;
-});
+ // if not backend filtered, filter row
+ if (!props.backendFiltering)
+ // return row is visible based on filters
+ return !isRowFiltered(row.value);
-/** check if has any searchable column. */
-const hasSearchableColumns = computed(() =>
- tableColumns.value.some((column) => column.searchable),
+ // return row is visible
+ return false;
+ });
+}
+
+/*
+ * Total data count.
+ * If backend paginated, use props total else use rows data length as pagination total.
+ */
+const tableTotal = computed(() =>
+ props.backendPagination ? props.total : tableRows.value.length,
);
-/** check if table is scrollable */
-const isScrollable = computed(() => {
- if (props.scrollable) return true;
- if (!tableColumns.value) return false;
- return tableColumns.value.some((column) => column.sticky);
+/** total rows count */
+const rowCount = computed(() => {
+ return tableTotal.value + ariaRowIndexStart.value;
});
-const slots = useSlots();
-
-/** check if table hast subheadings */
-const hasCustomSubheadings = computed(() => {
- if (slots.subheading) return true;
- return tableColumns.value.some((column) => !!column.subheading);
+/** aria-rowindex start value for tds based if it's Searchable or has subheadings */
+const ariaRowIndexStart = computed(() => {
+ let i = 1;
+ if (hasSearchableColumns.value) i++;
+ if (hasSubheadings.value) i++;
+ return i;
});
/**
@@ -517,51 +544,53 @@ function isRowEqual(
return el1 == el2;
}
-// --- Select Feature ---
+// #endregion --- TABLE ROWS ---
+
+// #region --- Select Feature ---
const tableSelectedRow = defineModel("selected", { default: undefined });
/** table arrow keys listener, change selection */
-function onArrowPressed(pos: number, event: KeyboardEvent): void {
- if (!visibleRows.value.length) return;
+function onArrowPressed(delta: 1 | -1, event: KeyboardEvent): void {
+ if (!availableRows.value.length) return;
let index =
- visibleRows.value.findIndex((row) =>
+ availableRows.value.findIndex((row) =>
isRowEqual(row.value, tableSelectedRow.value),
- ) + pos;
+ ) + delta;
- // prevent from going up from first and down from last
+ // check if index overflow
index =
- index < 0
- ? 0
- : index > visibleRows.value.length - 1
- ? visibleRows.value.length - 1
- : index;
+ index > availableRows.value.length - 1
+ ? availableRows.value.length - 1
+ : index;
+ // check if index underflow
+ index = index < 0 ? 0 : index;
- const row = visibleRows.value[index];
+ // get row element
+ const row = availableRows.value[index];
if (!props.isRowSelectable(row.value)) {
let newIndex: number | undefined;
- if (pos > 0) {
+ if (delta > 0) {
for (
let i = index;
- i < visibleRows.value.length && newIndex === undefined;
+ i < availableRows.value.length && newIndex === undefined;
i++
) {
- if (props.isRowSelectable(visibleRows.value[i].value))
+ if (props.isRowSelectable(availableRows.value[i].value))
newIndex = i;
}
} else {
for (let i = index; i >= 0 && newIndex === undefined; i--) {
- if (props.isRowSelectable(visibleRows.value[i].value))
+ if (props.isRowSelectable(availableRows.value[i].value))
newIndex = i;
}
}
- if (newIndex != undefined && newIndex >= 0) {
- selectRow(visibleRows.value[newIndex], index, event);
- }
+ if (newIndex != undefined && newIndex >= 0)
+ selectRow(availableRows.value[newIndex], event);
} else {
- selectRow(row, index, event);
+ selectRow(row, event);
}
}
@@ -569,8 +598,8 @@ function onArrowPressed(pos: number, event: KeyboardEvent): void {
* Row click listener.
* Emit all necessary events.
*/
-function selectRow(row: TableRow, index: number, event: Event): void {
- emits("click", row.value, index, event);
+function selectRow(row: TableRow, event: Event): void {
+ emits("click", row.value, row.index, event);
if (!props.selectable) return;
@@ -582,35 +611,53 @@ function selectRow(row: TableRow, index: number, event: Event): void {
emits("select", row.value, tableSelectedRow.value);
}
-// --- Filter Feature ---
+// #endregion --- Select Feature ---
+// #region --- Filter Feature ---
+
+/** search filter record alias { fieldKey: filterValue } */
const filters = ref>({});
+/** check if has any searchable column */
+const hasSearchableColumns = computed(() => {
+ return tableColumns.value.some((column) => column.searchable);
+});
+
+let debouncedFilter: ReturnType<
+ typeof useDebounce>
+>;
+
+// initialise and update debounces filter function
watch(
- filters,
- (value) => {
- if (props.backendFiltering) return;
- if (props.debounceSearch)
- useDebounce(
- () => handleFiltersChange(value),
- props.debounceSearch,
- )();
- else handleFiltersChange(value);
- },
- { deep: true },
+ () => props.debounceSearch,
+ (debounce) =>
+ (debouncedFilter = useDebounce(handleFiltersChange, debounce || 0)),
+ { immediate: true },
);
+// react on filters got changed
+watch(filters, (value) => debouncedFilter(value), { deep: true });
+
function handleFiltersChange(value: Record): void {
emits("filters-change", value);
- // recompute rows with updated filters
- processTableData();
+ // if not backend filtered, recompute rows visibility with updated filters
+ if (!props.backendFiltering) {
+ filterTableRows();
+ // force tableRows reactivity to update
+ triggerRef(tableRows);
+ }
}
function onFiltersEvent(event: Event): void {
emits("filters-event", props.filtersEvent, filters.value, event);
}
-/** check whether a row is filtered by filter or not */
+/**
+ * check whether a row is filtered by active filters or not
+ * @param row - row element
+ *
+ * @returns is row filtered in
+ * */
function isRowFiltered(row: T): boolean {
if (!Object.values(filters.value).filter(Boolean).length) return true;
return Object.entries(filters.value).some(([key, filter]) => {
@@ -637,19 +684,17 @@ function isRowFiltered(row: T): boolean {
});
}
-function filterRows(rows: TableRow[]): TableRow[] {
- return rows.filter((row) => isRowFiltered(row.value));
-}
+// #endregion --- Filter Feature ---
-// --- Sort Feature ---
+// #region --- Sort Feature ---
const currentSortColumn = ref>();
const isAsc = ref(true);
/** check if has any sortable column */
-const hasSortableColumns = computed(() =>
- tableColumns.value.some((column) => column.sortable),
-);
+const hasSortableColumns = computed(() => {
+ return tableColumns.value.some((column) => column.sortable);
+});
/** check if the column is the current sort column */
function isColumnSorted(column: TableColumnItem): boolean {
@@ -657,7 +702,7 @@ function isColumnSorted(column: TableColumnItem): boolean {
}
// call initSort only first time (for example async data)
-// initSort must be called after TableColumns got initialised first time
+// initSort must be called after async TableColumns got initialised first time
onMounted(() => nextTick(() => initSort()));
/** initial sorted column based on the default-sort prop */
@@ -685,7 +730,7 @@ function sort(
updateDirection = false,
event?: Event,
): void {
- if (!column || !column.sortable) return;
+ if (!column?.sortable) return;
if (updateDirection)
isAsc.value = isColumnSorted(column)
@@ -702,8 +747,9 @@ function sort(
);
currentSortColumn.value = column;
- // recompute rows with updated currentSortColumn
- processTableData();
+
+ // if not backend sorted, sort rows by mutating the tableRows array
+ if (!props.backendSorting) sortByColumn(tableRows.value);
}
function sortByField(field: string, direction: "asc" | "desc"): void {
@@ -726,10 +772,13 @@ function sortByColumn(rows: TableRow[]): TableRow[] {
? (a, b, asc): number => column.customSort!(a.value, b.value, asc)
: undefined,
isAsc.value,
+ true,
);
}
-// --- Checkable Feature ---
+// #endregion --- Sort Feature ---
+
+// #region --- Checkable Feature ---
const tableCheckedRows = defineModel("checkedRows", {
default: [],
@@ -737,7 +786,7 @@ const tableCheckedRows = defineModel("checkedRows", {
/** check if all rows in the page are checked */
const isAllChecked = computed(() => {
- const validVisibleData = visibleRows.value.filter((row) =>
+ const validVisibleData = availableRows.value.filter((row) =>
props.isRowCheckable(row.value),
);
if (validVisibleData.length === 0) return false;
@@ -748,7 +797,7 @@ const isAllChecked = computed(() => {
/** check if all rows in the page are checkable */
const isAllUncheckable = computed(
- () => !visibleRows.value.some((row) => props.isRowCheckable(row.value)),
+ () => !availableRows.value.some((row) => props.isRowCheckable(row.value)),
);
/** check if the row is checked (is added to the array) */
@@ -782,7 +831,7 @@ function checkAll(): void {
tableCheckedRows.value = [];
else {
// else set all visible rows as checked
- tableCheckedRows.value = visibleRows.value
+ tableCheckedRows.value = availableRows.value
.filter((row) => props.isRowCheckable(row.value))
.map((row) => row.value);
}
@@ -802,7 +851,9 @@ function checkRow(row: TableRow): void {
nextTick(() => emits("check", tableCheckedRows.value, row.value));
}
-// --- Detail Row Feature ---
+// #endregion --- Checkable Feature ---
+
+// #region --- Detail Row Feature ---
const visibleDetailedRows = defineModel("detailedRows", {
default: [],
@@ -847,7 +898,9 @@ function isActiveDetailRow(row: TableRow): boolean {
return props.detailed && isVisibleDetailRow(row);
}
-// --- Drag&Drop Feature ---
+// #endregion --- Detail Row Feature ---
+
+// #region --- Drag&Drop Feature ---
const isDraggingRow = ref(false);
const isDraggingColumn = ref(false);
@@ -859,104 +912,82 @@ const canDragColumn = computed(
);
/** emits drag start event */
-function handleDragStart(
- row: TableRow,
- index: number,
- event: DragEvent,
-): void {
+function handleDragStart(row: TableRow, event: DragEvent): void {
if (!props.draggable) return;
- emits("dragstart", row.value, index, event);
+ emits("dragstart", row.value, row.index, event);
}
/** emits drag leave event */
-function handleDragEnd(
- row: TableRow,
- index: number,
- event: DragEvent,
-): void {
+function handleDragEnd(row: TableRow, event: DragEvent): void {
if (!props.draggable) return;
- emits("dragend", row.value, index, event);
+ emits("dragend", row.value, row.index, event);
}
/** emits drop event */
-function handleDrop(row: TableRow, index: number, event: DragEvent): void {
+function handleDrop(row: TableRow, event: DragEvent): void {
if (!props.draggable) return;
- emits("drop", row.value, index, event);
+ emits("drop", row.value, row.index, event);
}
/** emits drag over event */
-function handleDragOver(
- row: TableRow,
- index: number,
- event: DragEvent,
-): void {
+function handleDragOver(row: TableRow, event: DragEvent): void {
if (!props.draggable) return;
- emits("dragover", row.value, index, event);
+ emits("dragover", row.value, row.index, event);
}
/** emits drag leave event */
-function handleDragLeave(
- row: TableRow,
- index: number,
- event: DragEvent,
-): void {
+function handleDragLeave(row: TableRow, event: DragEvent): void {
if (!props.draggable) return;
- emits("dragleave", row.value, index, event);
+ emits("dragleave", row.value, row.index, event);
}
/** emits drag start event (column) */
function handleColumnDragStart(
- column: TableColumn,
- index: number,
+ column: TableColumnItem,
event: DragEvent,
): void {
if (!canDragColumn.value) return;
isDraggingColumn.value = true;
- emits("columndragstart", column, index, event);
+ emits("columndragstart", column.value, column.index, event);
}
/** emits drag leave event (column) */
function handleColumnDragEnd(
- column: TableColumn,
- index: number,
+ column: TableColumnItem,
event: DragEvent,
): void {
if (!canDragColumn.value) return;
isDraggingColumn.value = false;
- emits("columndragend", column, index, event);
+ emits("columndragend", column.value, column.index, event);
}
/** emits drop event (column) */
-function handleColumnDrop(
- column: TableColumn,
- index: number,
- event: DragEvent,
-): void {
+function handleColumnDrop(column: TableColumnItem, event: DragEvent): void {
if (!canDragColumn.value) return;
- emits("columndrop", column, index, event);
+ emits("columndrop", column.value, column.index, event);
}
/** emits drag over event (column) */
function handleColumnDragOver(
- column: TableColumn,
- index: number,
+ column: TableColumnItem,
event: DragEvent,
): void {
if (!canDragColumn.value) return;
- emits("columndragover", column, index, event);
+ emits("columndragover", column.value, column.index, event);
}
/** emits drag leave event (column) */
function handleColumnDragLeave(
- column: TableColumn,
- index: number,
+ column: TableColumnItem,
event: DragEvent,
): void {
if (!canDragColumn.value) return;
- emits("columndragleave", column, index, event);
+ emits("columndragleave", column.value, column.index, event);
}
-// --- Computed Component Classes ---
+// #endregion --- Drag&Drop Feature ---
+
+// #region --- Computed Component Classes ---
const rootClasses = defineClasses(
["rootClass", "o-table__root"],
@@ -985,14 +1016,14 @@ const tableClasses = defineClasses(
computed(
() =>
(props.hoverable || props.selectable) &&
- !!visibleRows.value.length,
+ !!availableRows.value.length,
),
],
[
"emptyClass",
"o-table--empty",
null,
- computed(() => !visibleRows.value.length),
+ computed(() => !availableRows.value.length),
],
);
@@ -1016,17 +1047,25 @@ const footerClasses = defineClasses(["footerClass", "o-table__footer"]);
const thBaseClasses = defineClasses(["thClass", "o-table__th"]);
-const thCheckboxClasses = defineClasses([
- "thCheckboxClass",
- "o-table__th-checkbox",
-]);
+const thCheckboxClasses = defineClasses(
+ ["thCheckboxClass", "o-table__th-checkbox"],
+ [
+ "thStickyClass",
+ "o-table__th--sticky",
+ null,
+ computed(() => props.stickyCheckbox),
+ ],
+);
const thDetailedClasses = defineClasses([
"thDetailedClass",
- "o-table__th--detailed",
+ "o-table__th-detailed",
]);
-const thSubheadingClasses = defineClasses(["thSubheadingClass", "o-table__th"]);
+const thSubheadingClasses = defineClasses([
+ "thSubheadingClass",
+ "o-table__th-subheading",
+]);
const thSortIconClasses = defineClasses([
"thSortIconClass",
@@ -1043,6 +1082,13 @@ const trCheckedClasses = defineClasses([
"o-table__tr--checked",
]);
+const trEmptyClasses = defineClasses(["trEmptyClass", "o-table__tr-empty"]);
+
+const trDetailedClasses = defineClasses([
+ "trDetailedClass",
+ "o-table__tr-detail",
+]);
+
const tdBaseClasses = defineClasses(["tdClass", "o-table__td"]);
const tdCheckboxClasses = defineClasses(
@@ -1060,8 +1106,6 @@ const tdDetailedChevronClasses = defineClasses([
"o-table__td-chevron",
]);
-const detailedClasses = defineClasses(["detailedClass", "o-table__detail"]);
-
const mobileSortClasses = defineClasses([
"mobileSortClass",
"o-table__mobile-sort",
@@ -1076,7 +1120,7 @@ const paginationWrapperRootClasses = computed(() =>
getActiveClasses(paginationWrapperClasses),
);
-function rowClasses(row: TableRow, index: number): ClassBind[] {
+function rowClasses(row: TableRow): ClassBind[] {
const selectedClasses = isRowEqual(row.value, tableSelectedRow.value)
? trSelectedClasses.value
: [];
@@ -1085,21 +1129,28 @@ function rowClasses(row: TableRow, index: number): ClassBind[] {
const rowClass =
typeof props.rowClass === "function"
- ? props.rowClass(row.value, index) || ""
+ ? props.rowClass(row.value, row.index) || ""
: "";
return [...selectedClasses, ...checkedClasses, { [rowClass]: true }];
}
-// --- Expose Public Functionalities ---
+// #endregion --- Computed Component Classes ---
+
+// compute initial row visibility
+filterTableRows();
+
+// #region --- Expose Public Functionalities ---
/** expose functionalities for programmatic usage */
-defineExpose({ rows: tableData, sort: sortByField });
+defineExpose({ rows: tableRows, sort: sortByField });
+
+// #endregion
-
+
@@ -1157,7 +1208,6 @@ defineExpose({ rows: tableData, sort: sortByField });
:total="tableTotal"
:change="(page) => (tableCurrentPage = page)">
+ :tabindex="selectable || isScrollable ? 0 : undefined"
+ :aria-rowcount="rowCount"
+ :aria-colcount="columnCount"
+ v-bind="$attrs"
+ @keydown.prevent.up="onArrowPressed(-1, $event)"
+ @keydown.prevent.down="onArrowPressed(1, $event)"
+ @keydown.prevent.home="selectRow(availableRows[0], $event)"
+ @keydown.prevent.end="
+ selectRow(availableRows[availableRows.length - 1], $event)
+ ">
-
+
+
+ :class="[...thBaseClasses, ...thDetailedClasses]"
+ :aria-colindex="1"
+ aria-hidden="true" />
+
+ :class="[...thBaseClasses, ...thCheckboxClasses]"
+ :aria-colindex="showDetailRowIcon ? 2 : 1">
-
-
+
+
-
-
-
+ :props="{
+ column: column.value,
+ index: column.index,
+ }" />
+
+
{{ column.label }}
+ v-if="column.sortable"
+ v-show="isColumnSorted(column)"
+ :class="thSortIconClasses"
+ :aria-hidden="!isColumnSorted(column)">
-
-
+
+
+ :class="[...thBaseClasses, ...thCheckboxClasses]"
+ :aria-colindex="
+ ariaColIndexStart + tableColumns.length
+ ">
+ :class="[...thBaseClasses, ...thDetailedClasses]"
+ aria-hidden="true" />
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
+
+
+
-
+
+
+
-
-
+
+
-
-
- {{ column.subheading }}
-
-
+ :props="{
+ column: column.value,
+ index: column.index,
+ }" />
+
+ {{ column.subheading }}
+
+
+
+
+
+ v-for="(row, rowIndex) in tableRows"
+ :key="row.key">
+ @dragstart="handleDragStart(row, $event)"
+ @dragend="handleDragEnd(row, $event)"
+ @drop="handleDrop(row, $event)"
+ @dragover="handleDragOver(row, $event)"
+ @dragleave="handleDragLeave(row, $event)">
+ :aria-label="`Open ${row.label} details`"
+ @click.prevent="toggleDetails(row)"
+ @keydown.prevent.enter="toggleDetails(row)"
+ @keydown.prevent.space="
+ toggleDetails(row)
+ " />
@@ -1426,40 +1555,46 @@ defineExpose({ rows: tableData, sort: sortByField });
-
+
+
+
-
+
-
+ -->ID First Name Last Name Date Gender
@@ -17,18 +36,101 @@ exports[`OTable tests > render correctly 1`] = `
-
-
+
+
+
+
+
+
+
+
+
+ ID
+ First Name
+ Last Name
+ Date
+ Gender
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+ 1
+ Jesse
+ Simmons
+ 2016-10-15 13:43:27
+ Male
+
+
+
+
+
+
+
+
+
+ 2
+ John
+ Jacobs
+ 2016-12-15 06:00:53
+ Male
+
+
+
+
+
+
+
+
+
+ 3
+ Tina
+ Gilbert
+ 2016-04-26 06:26:28
+ Female
+
+
+
+
+
+
+
+
+
+ 4
+ Clarence
+ Flores
+ 2016-04-10 10:28:46
+ Male
+
+
+
+
+
+
+
+
+
+ 5
+ Anne
+ Lee
+ 2016-12-06 14:38:38
+ Female
+
+
+
diff --git a/packages/oruga/src/components/table/tests/table.axe.test.ts b/packages/oruga/src/components/table/tests/table.axe.test.ts
new file mode 100644
index 000000000..4b220f97f
--- /dev/null
+++ b/packages/oruga/src/components/table/tests/table.axe.test.ts
@@ -0,0 +1,216 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { enableAutoUnmount, mount } from "@vue/test-utils";
+import { axe } from "jest-axe";
+
+import OTable from "../Table.vue";
+import { nextTick } from "vue";
+import type { TableColumn } from "../types";
+import type { TableProps } from "../props";
+
+describe("Table axe tests", () => {
+ enableAutoUnmount(afterEach);
+
+ const data = [
+ {
+ id: 1,
+ user: { first_name: "Jesse", last_name: "Simmons" },
+ date: "2016/10/15 13:43:27",
+ gender: "Male",
+ },
+ {
+ id: 2,
+ user: { first_name: "John", last_name: "Jacobs" },
+ date: "2016/12/15 06:00:53",
+ gender: "Male",
+ },
+ {
+ id: 3,
+ user: { first_name: "Tina", last_name: "Gilbert" },
+ date: "2016/04/26 06:26:28",
+ gender: "Female",
+ },
+ {
+ id: 4,
+ user: { first_name: "Clarence", last_name: "Flores" },
+ date: "2016/04/10 10:28:46",
+ gender: "Male",
+ },
+ {
+ id: 5,
+ user: { first_name: "Anne", last_name: "Lee" },
+ date: "2016/12/06 14:38:38",
+ gender: "Female",
+ },
+ {
+ id: 6,
+ user: { first_name: "Sara", last_name: "Armstrong" },
+ date: "2016/09/23 18:50:04",
+ gender: "Female",
+ },
+ {
+ id: 7,
+ user: { first_name: "Anthony", last_name: "Webb" },
+ date: "2016/08/30 23:49:38",
+ gender: "Male",
+ },
+ {
+ id: 8,
+ user: { first_name: "Andrew", last_name: "Greene" },
+ date: "2016/11/20 14:57:47",
+ gender: "Male",
+ },
+ {
+ id: 9,
+ user: { first_name: "Russell", last_name: "White" },
+ date: "2016/07/13 09:29:49",
+ gender: "Male",
+ },
+ {
+ id: 10,
+ user: { first_name: "Lori", last_name: "Hunter" },
+ date: "2016/12/09 01:44:05",
+ gender: "Female",
+ },
+ {
+ id: 11,
+ user: { first_name: "Ronald", last_name: "Wood" },
+ date: "2016/12/04 02:23:48",
+ gender: "Male",
+ },
+ {
+ id: 12,
+ user: { first_name: "Michael", last_name: "Harper" },
+ date: "2016/07/27 13:28:15",
+ gender: "Male",
+ },
+ {
+ id: 13,
+ user: { first_name: "George", last_name: "Dunn" },
+ date: "2017/03/07 12:26:52",
+ gender: "Male",
+ },
+ {
+ id: 14,
+ user: { first_name: "Eric", last_name: "Rogers" },
+ date: "2016/06/07 05:41:52",
+ gender: "Male",
+ },
+ {
+ id: 15,
+ user: { first_name: "Juan", last_name: "Meyer" },
+ date: "2017/02/01 04:56:34",
+ gender: "Male",
+ },
+ ];
+
+ const columns: TableColumn<(typeof data)[number]>[] = [
+ {
+ field: "id",
+ label: "ID",
+ width: "40",
+ numeric: true,
+ sortable: true,
+ },
+ {
+ field: "user.first_name",
+ label: "First Name",
+ sortable: true,
+ },
+ {
+ field: "user.last_name",
+ label: "Last Name",
+ sortable: true,
+ },
+ {
+ field: "date",
+ label: "Date",
+ position: "centered",
+ sortable: true,
+ formatter: (v): string => new Date(String(v)).toLocaleDateString(),
+ },
+ {
+ field: "gender",
+ label: "Gender",
+ sortable: true,
+ },
+ ];
+
+ const a11yCases: {
+ title: string;
+ props: TableProps<(typeof data)[number]>;
+ }[] = [
+ {
+ title: "axe table - base case",
+ props: { data, columns },
+ },
+ {
+ title: "axe table - bordered case",
+ props: { data, columns, bordered: true },
+ },
+ {
+ title: "axe table - striped case",
+ props: { data, columns, striped: true },
+ },
+ {
+ title: "axe table - narrowed case",
+ props: { data, columns, narrowed: true },
+ },
+ {
+ title: "axe table - hoverable case",
+ props: { data, columns, hoverable: true },
+ },
+ {
+ title: "axe table - selectable case",
+ props: { data, columns, selectable: true },
+ },
+ {
+ title: "axe table - checkable case",
+ props: {
+ data,
+ columns,
+ checkable: true,
+ stickyCheckbox: true,
+ headerCheckable: true,
+ },
+ },
+ {
+ title: "axe table - draggable case",
+ props: { data, columns, draggable: true, draggableColumn: true },
+ },
+ {
+ title: "axe table - scrollable case",
+ props: {
+ data,
+ columns: [...columns, ...columns, ...columns],
+ scrollable: true,
+ },
+ },
+ {
+ title: "axe table - detailed case",
+ props: { data, columns, detailed: true, showDetailIcon: true },
+ },
+ {
+ title: "axe table - pagination case",
+ props: {
+ data,
+ columns,
+ paginated: true,
+ perPage: 2,
+ currentPage: 2,
+ },
+ },
+ {
+ title: "axe table - loading case",
+ props: { data, columns, loading: true },
+ },
+ ];
+
+ test.each(a11yCases)("$title", async ({ props }) => {
+ const wrapper = mount>(OTable, {
+ props: props,
+ attachTo: document.body,
+ });
+ await nextTick(); // await child component rendering
+ expect(await axe(wrapper.element)).toHaveNoViolations();
+ });
+});
diff --git a/packages/oruga/src/components/table/tests/table.test.ts b/packages/oruga/src/components/table/tests/table.test.ts
index efa5e1cc9..a11744e05 100644
--- a/packages/oruga/src/components/table/tests/table.test.ts
+++ b/packages/oruga/src/components/table/tests/table.test.ts
@@ -1,16 +1,501 @@
-import { describe, test, expect, afterEach } from "vitest";
-import { enableAutoUnmount, mount } from "@vue/test-utils";
+import { describe, test, expect, afterEach, vi, beforeEach } from "vitest";
+import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
+import { nextTick } from "vue";
+
+import type { TableColumn } from "../types";
import OTable from "@/components/table/Table.vue";
describe("OTable tests", () => {
enableAutoUnmount(afterEach);
- test("render correctly", () => {
- const wrapper = mount(OTable);
+ const data = [
+ {
+ id: 1,
+ first_name: "Jesse",
+ last_name: "Simmons",
+ date: "2016-10-15 13:43:27",
+ gender: "Male",
+ },
+ {
+ id: 2,
+ first_name: "John",
+ last_name: "Jacobs",
+ date: "2016-12-15 06:00:53",
+ gender: "Male",
+ },
+ {
+ id: 3,
+ first_name: "Tina",
+ last_name: "Gilbert",
+ date: "2016-04-26 06:26:28",
+ gender: "Female",
+ },
+ {
+ id: 4,
+ first_name: "Clarence",
+ last_name: "Flores",
+ date: "2016-04-10 10:28:46",
+ gender: "Male",
+ },
+ {
+ id: 5,
+ first_name: "Anne",
+ last_name: "Lee",
+ date: "2016-12-06 14:38:38",
+ gender: "Female",
+ },
+ ];
+
+ const columns: TableColumn<(typeof data)[number]>[] = [
+ {
+ field: "id",
+ label: "ID",
+ width: "40",
+ numeric: true,
+ sortable: true,
+ },
+ {
+ field: "first_name",
+ label: "First Name",
+ sortable: true,
+ },
+ {
+ field: "last_name",
+ label: "Last Name",
+ sortable: true,
+ },
+ {
+ field: "date",
+ label: "Date",
+ position: "centered",
+ sortable: true,
+ },
+ {
+ field: "gender",
+ label: "Gender",
+ sortable: true,
+ },
+ ];
+
+ test("render correctly", async () => {
+ const wrapper = mount>(OTable, {
+ props: { data, columns },
+ });
+ await nextTick(); // await child component rendering
+
expect(!!wrapper.vm).toBeTruthy();
expect(wrapper.exists()).toBeTruthy();
expect(wrapper.attributes("data-oruga")).toBe("table");
expect(wrapper.html()).toMatchSnapshot();
});
+
+ test("holds columns", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "default", width: "100px" },
+ { label: "pecent", width: "50%" },
+ { label: "fixed_num", width: 100 },
+ { label: "fixed_str", width: "100" },
+ ],
+ },
+ });
+ await nextTick();
+
+ const headers = wrapper.findAll("th");
+
+ expect(headers).toHaveLength(4);
+
+ const cols = headers.filter((th) => th.find("span"));
+ expect(cols).toHaveLength(4);
+
+ expect(cols[0].attributes("style")).toBe("width: 100px;");
+ expect(cols[1].attributes("style")).toBe("width: 50%;");
+ expect(cols[2].attributes("style")).toBe("width: 100px;");
+ expect(cols[3].attributes("style")).toBe("width: 100px;");
+ });
+
+ test("displays all data", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(5);
+ });
+
+ describe("test column props", () => {
+ test("test column label", async () => {
+ const wrapper = mount>(
+ OTable,
+ { props: { data, columns } },
+ );
+ await nextTick(); // await child component rendering
+
+ const table = wrapper.find("table");
+ expect(table.exists()).toBeTruthy();
+ const ths = table.findAll("th");
+ expect(ths).toHaveLength(columns.length);
+
+ for (let i = 0; i < ths.length; i++) {
+ expect(ths[i].text()).toBe(columns[i].label);
+ }
+ });
+
+ test("test column field", async () => {
+ const wrapper = mount>(
+ OTable,
+ { props: { data, columns } },
+ );
+ await nextTick(); // await child component rendering
+
+ const table = wrapper.find("table");
+ expect(table.exists()).toBeTruthy();
+ const trs = table.findAll("tr");
+ expect(trs).toHaveLength(data.length + 1);
+
+ for (let i = 1; i < trs.length; i++) {
+ const tds = trs[i].findAll("td");
+ expect(tds).toHaveLength(columns.length);
+
+ for (let j = 0; j < columns.length; j++) {
+ expect(tds[j].text()).toBe(
+ String(data[i - 1][columns[j].field!]),
+ );
+ }
+ }
+ });
+
+ test("test column formatter", async () => {
+ const columns: TableColumn<(typeof data)[number]>[] = [
+ {
+ label: "ID",
+ width: "40",
+ numeric: true,
+ sortable: true,
+ formatter: (a, b) => {
+ expect(a).toBe(b);
+ return "abc";
+ },
+ },
+ {
+ label: "First Name",
+ sortable: true,
+ formatter: (a, b) => {
+ expect(a).toBe(b);
+ return "abc";
+ },
+ },
+ ];
+
+ const wrapper = mount>(
+ OTable,
+ { props: { data, columns } },
+ );
+ await nextTick(); // await child component rendering
+
+ const table = wrapper.find("table");
+ expect(table.exists()).toBeTruthy();
+ const trs = table.findAll("tr");
+ expect(trs).toHaveLength(data.length + 1);
+
+ for (let i = 1; i < trs.length; i++) {
+ const tds = trs[i].findAll("td");
+ expect(tds).toHaveLength(columns.length);
+
+ for (let j = 0; j < columns.length; j++) {
+ expect(tds[j].text()).toBe(
+ columns[j].formatter!(data[i], data[i]),
+ );
+ }
+ }
+ });
+
+ test("test column thAttrs and tdAttrs", async () => {
+ const columns: TableColumn<(typeof data)[number]>[] = [
+ {
+ field: "id",
+ label: "ID",
+ width: "40",
+ numeric: true,
+ sortable: true,
+ thAttrs: { class: "th-id" },
+ tdAttrs: { class: "td-id" },
+ },
+ {
+ field: "abc",
+ label: "ABC",
+ sortable: true,
+ thAttrs: { class: "th-abc" },
+ tdAttrs: { class: "td-abc" },
+ },
+ ];
+
+ const wrapper = mount>(
+ OTable,
+ { props: { data, columns } },
+ );
+ await nextTick(); // await child component rendering
+
+ const table = wrapper.find("table");
+ expect(table.exists()).toBeTruthy();
+ const ths = table.findAll("th");
+ expect(ths).toHaveLength(columns.length);
+ expect(ths[0].classes("th-id")).toBeTruthy();
+ expect(ths[1].classes("th-abc")).toBeTruthy();
+
+ const tds = table.findAll("td");
+ expect(tds).toHaveLength(columns.length * data.length);
+
+ for (let i = 0; i < tds.length; i++) {
+ expect(
+ tds[i].classes(i % 2 === 0 ? "td-id" : "td-abc"),
+ ).toBeTruthy();
+ }
+ });
+ });
+
+ describe("test searchable", () => {
+ const data = [
+ { id: 1, name: "Jesse" },
+ { id: 2, name: "João" },
+ { id: 3, name: "Tina" },
+ { id: 4, name: "Anne" },
+ { id: 5, name: "Clarence" },
+ ];
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ vi.restoreAllMocks();
+ });
+
+ test("displays filter row when at least one column is searchable", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const header = wrapper.find("thead");
+ expect(header.exists()).toBeTruthy();
+
+ const headRows = header.findAll("tr");
+ expect(headRows).toHaveLength(2);
+
+ const inputs = headRows[1].findAll("input");
+ expect(inputs).toHaveLength(1);
+ });
+
+ test("displays filter input only on searchable columns", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const headRows = wrapper.findAll("thead tr");
+ expect(headRows).toHaveLength(2);
+ const filterCells = headRows[1].findAll("th");
+
+ expect(filterCells[0].find("input").exists()).toBeFalsy(); // ID column is not searchable
+ expect(filterCells[1].find("input").exists()).toBeTruthy(); // Name column is searchable
+ });
+
+ test("displays filtered data when searching", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const header = wrapper.find("thead");
+
+ const inputs = header.findAll("input");
+ expect(inputs).toHaveLength(1);
+
+ const input = inputs[0];
+
+ await input.setValue("J");
+ await input.trigger("input");
+ vi.runAllTimers(); // run debounce timer
+ await flushPromises();
+
+ const bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(2); // Jesse and João
+
+ expect(wrapper.emitted("filters-change")).toHaveLength(1);
+ });
+
+ test("displays filtered data when searching and updating data", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const input = wrapper.find("thead input");
+ expect(input.exists()).toBeTruthy();
+
+ await input.setValue("J");
+ await input.trigger("input");
+ vi.runAllTimers(); // run debounce timer
+ await flushPromises();
+
+ let bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(2); // Jesse and João
+
+ wrapper.setProps({
+ data: [...data, { id: 6, name: "Justin" }],
+ });
+ await nextTick();
+
+ console.log(wrapper.find("tbody").html());
+
+ bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(3); // Jesse, João and Justin
+ });
+
+ test("displays filtered data when searching by name without accent", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const input = wrapper.find("thead input");
+ expect(input.exists()).toBeTruthy();
+
+ await input.setValue("Joao");
+ await input.trigger("input");
+ vi.runAllTimers(); // run debounce timer
+ await flushPromises();
+
+ const bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(1); // João
+ });
+
+ test("displays filtered data when searching by name with accent", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ },
+ });
+ await nextTick();
+
+ const input = wrapper.find("thead input");
+ expect(input.exists()).toBeTruthy();
+
+ await input.setValue("João");
+ await input.trigger("input");
+ vi.runAllTimers(); // run debounce timer
+ await flushPromises();
+
+ const bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(1); // João
+ });
+
+ test("debounce search filtering when debounce-search is defined", async () => {
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ data,
+ debounceSearch: 1000,
+ },
+ });
+ await nextTick();
+
+ const input = wrapper.find("thead input");
+ expect(input.exists()).toBeTruthy();
+
+ for (let i = 0; i < 10; i++) {
+ await input.setValue("J".repeat(10 - i));
+ await input.trigger("input");
+ await setTimeout(() => {}, 500);
+ const bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(5); // No filtering yet
+ }
+
+ vi.runAllTimers(); // run debounce timer
+
+ const bodyRows = wrapper.findAll("tbody tr");
+ expect(bodyRows).toHaveLength(5); // Filtering after debounce
+ });
+ });
+
+ describe("test checkable", () => {
+ test("tests isAllUncheckable method", async () => {
+ const isRowCheckable = vi.fn(() => false);
+
+ const wrapper = mount(OTable, {
+ props: {
+ columns: [
+ { label: "ID", field: "id", numeric: true },
+ { label: "Name", field: "name", searchable: true },
+ ],
+ checkable: true,
+ isRowCheckable: isRowCheckable,
+ paginated: false,
+ data: [
+ {
+ id: 1,
+ first_name: "Jesse",
+ last_name: "Simmons",
+ date: "2016-10-15 13:43:27",
+ gender: "Male",
+ },
+ ],
+ },
+ });
+ await nextTick();
+
+ const body = wrapper.find("tbody");
+ const checkboxes = body.findAll("input");
+ expect(checkboxes).toHaveLength(1);
+ expect(checkboxes[0].attributes("disabled")).toBe("");
+ });
+ });
});
diff --git a/packages/oruga/src/components/table/types.ts b/packages/oruga/src/components/table/types.ts
index c97340f19..668fde70a 100644
--- a/packages/oruga/src/components/table/types.ts
+++ b/packages/oruga/src/components/table/types.ts
@@ -1,23 +1,19 @@
import type { ComponentPublicInstance, Slots, StyleValue } from "vue";
-import type { ObjectMap, ProviderItem } from "@/composables";
+import type { OptionsItem, ProviderItem } from "@/composables";
import type { ClassBind } from "@/types";
import type { TableColumnProps } from "./props";
-export type TableRow = ObjectMap[number];
+export type TableRow = OptionsItem & {
+ /** table index position of the current row */
+ index: number;
+};
export type TableColumn<
T = unknown,
K extends keyof T | string = string,
> = TableColumnProps;
-export type TableColumns = (
- | {
- [K in keyof T]: TableColumn;
- }[keyof T]
- | TableColumn
-)[];
-
export type TableColumnComponent = TableColumn & {
$el: ComponentPublicInstance;
$slots: Slots;
@@ -32,6 +28,7 @@ export type TableComponent = {
export type TableColumnItem = Omit &
TableColumnComponent & {
+ value: TableColumn;
thAttrsData: object;
tdAttrsData: Array;
};
diff --git a/packages/oruga/src/components/types.ts b/packages/oruga/src/components/types.ts
index c6a093fc6..5aa4dcad3 100644
--- a/packages/oruga/src/components/types.ts
+++ b/packages/oruga/src/components/types.ts
@@ -618,6 +618,8 @@ See icon library documentation for custom classes. */
/** Use `clip` to remove the body scrollbar, `keep` to have a non scrollable scrollbar to avoid shifting background,
but will set body to position fixed, might break some layouts. */
scroll: "clip" | "keep";
+ /** Role attribute to be passed to the div wrapper for better accessibility */
+ role: string;
/** Class of the root element */
rootClass: ClassDefinition;
/** Class for the root element when fullpage */
@@ -1245,8 +1247,6 @@ In addition, any CSS selector string or an actual DOM node can be used. */
footerClass: ClassDefinition;
/** Class of the Table when it is empty */
emptyClass: ClassDefinition;
- /** Class of the Table row detail */
- detailedClass: ClassDefinition;
/** Class of the Table when is bordered */
borderedClass: ClassDefinition;
/** Class of the Table when rows are striped */
@@ -1263,6 +1263,10 @@ In addition, any CSS selector string or an actual DOM node can be used. */
trSelectedClass: ClassDefinition;
/** Class of the Table row when checkable and checked */
trCheckedClass: ClassDefinition;
+ /** Class of the detail Table row */
+ trDetailedClass: ClassDefinition;
+ /** Class of the Table row when table is empty */
+ trEmptyClass: ClassDefinition;
/** Class of the Table `th` element */
thClass: ClassDefinition;
/** Class of the Table `th` element when component is positioned */
diff --git a/packages/oruga/src/composables/index.ts b/packages/oruga/src/composables/index.ts
index 4761c3ca6..e55742a15 100644
--- a/packages/oruga/src/composables/index.ts
+++ b/packages/oruga/src/composables/index.ts
@@ -7,7 +7,6 @@ export * from "./useDebounce";
export * from "./useParentProvider";
export * from "./useClickOutside";
export * from "./useScrollingParent";
-export * from "./useObjectMap";
export * from "./useOptions";
export * from "./usePreventScrolling";
export * from "./useSequentialId";
diff --git a/packages/oruga/src/composables/tests/useOptions.test.ts b/packages/oruga/src/composables/tests/useOptions.test.ts
index c3015624c..e356f833e 100644
--- a/packages/oruga/src/composables/tests/useOptions.test.ts
+++ b/packages/oruga/src/composables/tests/useOptions.test.ts
@@ -3,9 +3,9 @@ import {
checkOptionsEmpty,
filterOptionsItems,
findOption,
- firstValidOption,
+ firstViableOption,
isGroupOption,
- isOptionValid,
+ isOptionViable,
normalizeOptions,
toOptionsGroup,
toOptionsList,
@@ -344,60 +344,52 @@ describe("useOptions tests", () => {
});
test("test empty list", () => {
- const filteredOptions = filterOptionsItems([], "abc");
- expect(filteredOptions).toEqual([]);
+ const options = [];
+ filterOptionsItems(options, (o) => o.label == "abc");
+ expect(options).toEqual([]);
});
test("test filter single value", () => {
- const filteredOptions = filterOptionsItems(options, "#FFFFFF");
- expect(filteredOptions).toHaveLength(options.length);
- expect(filteredOptions[0].options[2]).toEqual({
+ const length = options.length;
+ filterOptionsItems(options, (o) => o.label != "#FFFFFF");
+ expect(options).toHaveLength(length);
+ expect(options[0].options[2]).toEqual({
label: "#FFFFFF",
value: "#FFFFFF",
hidden: false,
});
- expect(
- filteredOptions[0].options.filter((o) => !o.hidden),
- ).toHaveLength(1);
+ expect(options[0].options.filter((o) => !o.hidden)).toHaveLength(1);
});
test("test filter multiple value", () => {
- const filteredOptions = filterOptionsItems(options, "#");
- expect(filteredOptions).toHaveLength(options.length);
- expect(filteredOptions.every((o) => o.hidden)).toBeFalsy();
- expect(
- filteredOptions[0].options.every((o) => !o.hidden),
- ).toBeTruthy();
+ const length = options.length;
+ filterOptionsItems(options, (o) => o.label.startsWith("#"));
+ expect(options).toHaveLength(length);
+ expect(options.every((o) => o.hidden)).toBeFalsy();
+ expect(options[0].options.every((o) => o.hidden)).toBeTruthy();
});
test("test filter by invalid value", () => {
- const filteredOptions = filterOptionsItems(options, "abc");
- expect(filteredOptions).toHaveLength(options.length);
- expect(filteredOptions.every((o) => o.hidden)).toBeTruthy();
- expect(
- filteredOptions[0].options.every((o) => o.hidden),
- ).toBeTruthy();
+ const length = options.length;
+ filterOptionsItems(options, (o) => o.label != "abc");
+ expect(options).toHaveLength(length);
+ expect(options.every((o) => o.hidden)).toBeTruthy();
+ expect(options[0].options.every((o) => o.hidden)).toBeTruthy();
});
test("test filter with custom function", () => {
- const filteredOptions = filterOptionsItems(
- options,
- "FFFFFF",
- (option, filter) => {
- return !String(option).includes("#" + filter);
- },
+ const length = options.length;
+ filterOptionsItems(options, (option) =>
+ String(option).includes("#FFFFFF"),
);
- expect(filteredOptions).toHaveLength(options.length);
- expect(filteredOptions[0].options[2]).toEqual({
+ expect(options).toHaveLength(length);
+ expect(options[0].options[2]).toEqual({
label: "#FFFFFF",
value: "#FFFFFF",
hidden: false,
});
-
- expect(
- filteredOptions[0].options.filter((o) => !o.hidden),
- ).toHaveLength(1);
+ expect(options[1].options.filter((o) => !o.hidden)).toHaveLength(1);
});
});
@@ -660,7 +652,7 @@ describe("useOptions tests", () => {
describe("test firstValidOption", () => {
test("test empty list", () => {
- const foundOption = firstValidOption([]);
+ const foundOption = firstViableOption([]);
expect(foundOption).toBeUndefined();
});
@@ -685,7 +677,7 @@ describe("useOptions tests", () => {
value: "#2b2b35",
},
];
- const foundOption = firstValidOption(options);
+ const foundOption = firstViableOption(options);
expect(foundOption).toStrictEqual({
label: "#FFFFFF",
value: "#FFFFFF",
@@ -728,7 +720,7 @@ describe("useOptions tests", () => {
},
];
- const foundOption = firstValidOption(options);
+ const foundOption = firstViableOption(options);
expect(foundOption).toStrictEqual({
label: "#ff985d",
value: "#ff985d",
@@ -774,7 +766,7 @@ describe("useOptions tests", () => {
},
];
- const foundOption = firstValidOption(options);
+ const foundOption = firstViableOption(options);
expect(foundOption).toStrictEqual({
label: "Red",
value: "#ff0000",
@@ -789,7 +781,7 @@ describe("useOptions tests", () => {
value: "#ff985d",
};
- const isValid = isOptionValid(option);
+ const isValid = isOptionViable(option);
expect(isValid).toBeTruthy();
});
@@ -800,7 +792,7 @@ describe("useOptions tests", () => {
hidden: true,
};
- const isValid = isOptionValid(option);
+ const isValid = isOptionViable(option);
expect(isValid).toBeFalsy();
});
@@ -811,7 +803,7 @@ describe("useOptions tests", () => {
attrs: { disabled: true },
};
- const isValid = isOptionValid(option);
+ const isValid = isOptionViable(option);
expect(isValid).toBeFalsy();
});
});
diff --git a/packages/oruga/src/composables/useObjectMap.ts b/packages/oruga/src/composables/useObjectMap.ts
deleted file mode 100644
index 2b9cc5556..000000000
--- a/packages/oruga/src/composables/useObjectMap.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { toValue, type MaybeRefOrGetter } from "vue";
-
-export type ObjectMap = Array<{
- key: string | number;
- value: T;
-}>;
-
-/** wrap entities in an array of objects with key/value attributes */
-export function useObjectMap(
- values: MaybeRefOrGetter> | undefined,
- key: string | number | symbol | undefined,
- uuid: () => string,
-): ObjectMap {
- if (!values || !toValue(values)?.length) return [];
- return toValue(values).map((value: T) => ({
- value: toValue(value),
- key:
- // if no key is given and data is object, create unique row id for each row
- key && value && typeof value === "object"
- ? (value[key as keyof T] as string) || uuid()
- : uuid(),
- }));
-}
diff --git a/packages/oruga/src/composables/useOptions.ts b/packages/oruga/src/composables/useOptions.ts
index 6e7827689..d8b3be536 100644
--- a/packages/oruga/src/composables/useOptions.ts
+++ b/packages/oruga/src/composables/useOptions.ts
@@ -2,7 +2,7 @@ import { toValue, type MaybeRefOrGetter } from "vue";
import { isEqual } from "@/utils/helpers";
/**
- * Options should always be formatted as an array of objects with label and value properties.
+ * Internal OptionsItem representation object with additional state information.
*
* @internal
*/
@@ -14,8 +14,7 @@ export type OptionsItem = OptionsPropItem & {
};
/**
- * Options should always be formatted as an array of objects with label and value
- * properties.
+ * Options should always be formatted as an array of objects with label and value properties.
*
* @public
*/
@@ -168,7 +167,7 @@ export function normalizeOptions<
* @returns option is OptionsGroupItem
*/
export function isGroupOption(
- option: OptionsItem | OptionsGroupItem,
+ option: Partial,
): option is OptionsGroupItem {
return (
option && typeof option === "object" && Array.isArray(option.options)
@@ -202,56 +201,31 @@ export function toOptionsList(
}
/**
- * Applies an reactive filter for a list of options {@link OptionsItem | OptionsGroupItem} based on a given value.
+ * Applies an filter function for a list of options {@link OptionsItem | OptionsGroupItem}.
* Options are filtered by setting the hidden attribute.
- * A custom filter function can be given.
- * @param options Options to filter
- * @param value Value to filter for
- * @param customFilter optional filter function
+ * The options reactivity is not triggered by this.
+ * @param options - Options to filter
+ * @param filter - filter function
*/
-export function filterOptionsItems<
- V,
- O extends OptionsItem[] | OptionsGroupItem[],
->(
- options: MaybeRefOrGetter,
- value: MaybeRefOrGetter,
- customFilter?: (option: V, value: string) => boolean,
-): O {
- function filter(option: OptionsItem, value: string): boolean {
- if (typeof customFilter === "function")
- return customFilter(option.value, toValue(value));
- else
- return !String(option.label)
- .toLowerCase()
- .includes(value?.toLowerCase());
- }
-
- function filterOptions(
- options: OptionsItem[] | OptionsGroupItem[],
- value: string,
- ): void {
- options.forEach((option: OptionsItem | OptionsGroupItem) => {
- if (isGroupOption(option)) {
- filterOptions(option.options, value);
- // hide the whole group if every group options is hidden
- option.hidden = option.options.every((option) => option.hidden);
- } else {
- // hide the option if filtered
- option.hidden = filter(option, value);
- }
- });
- }
-
- // filter options by value
- filterOptions(toValue(options), toValue(value));
-
- // return options as new array
- return [...toValue(options)] as O;
+export function filterOptionsItems(
+ options: MaybeRefOrGetter[] | OptionsGroupItem[]>,
+ filter: (option: OptionsItem) => boolean,
+): void {
+ toValue(options).forEach((option: OptionsItem | OptionsGroupItem) => {
+ if (isGroupOption(option)) {
+ filterOptionsItems(option.options, filter);
+ // hide the whole group if every group options is hidden
+ option.hidden = option.options.every((option) => option.hidden);
+ } else {
+ // hide the option if filtered
+ option.hidden = filter(option);
+ }
+ });
}
/**
* Checks if no options are given or every existing options are hidden.
- * @param options - A list of {@link OptionsItem | OptionsGroupItem} to check for a given value
+ * @param options - A list of {@link OptionsItem | OptionsGroupItem} to check for a given value
*
* @returns boolean
*/
@@ -266,7 +240,7 @@ export function checkOptionsEmpty(
// check if every options are hidden
return checkOptionsEmpty(option.options);
// check if option is hidden
- else return !isOptionValid(option);
+ else return !isOptionViable(option);
});
}
@@ -304,7 +278,7 @@ export function findOption(
* Given an options list, find the first value.
* @param options - An options list (with groups)
*/
-export function firstValidOption(
+export function firstViableOption(
options:
| MaybeRefOrGetter[]>
| MaybeRefOrGetter[]>,
@@ -315,16 +289,16 @@ export function firstValidOption(
if (typeof option !== "object" && option) continue;
if (isGroupOption(option)) {
// option in group
- const found = firstValidOption(option.options);
+ const found = firstViableOption(option.options);
if (found !== undefined) return found;
}
- // check if option is valid
- else if (isOptionValid(option)) return option;
+ // check if option is viable
+ else if (isOptionViable(option)) return option;
}
return undefined;
}
-export function isOptionValid(option: MaybeRefOrGetter): boolean {
+export function isOptionViable(option: MaybeRefOrGetter): boolean {
return !toValue(option).hidden && !toValue(option).attrs?.disabled;
}
diff --git a/packages/oruga/src/types/utils.ts b/packages/oruga/src/types/utils.ts
index d729b2d5f..d956755d5 100644
--- a/packages/oruga/src/types/utils.ts
+++ b/packages/oruga/src/types/utils.ts
@@ -21,6 +21,17 @@ export type DynamicComponent = string | object | CallableFunction | Component;
/** Define a property as required */
export type WithRequired = T & { [P in K]-?: T[P] };
+/** Remove generic <[x: string]: any> index signature structure from type */
+export type RemoveIndex = {
+ [K in keyof T as string extends K
+ ? never
+ : number extends K
+ ? never
+ : symbol extends K
+ ? never
+ : K]: T[K];
+};
+
/** Custom type helper which extracts the `$emits` type of an component and converts it to an props object. */
export type ComponentEmits = EmitsToProps>;
diff --git a/packages/oruga/src/utils/helpers.ts b/packages/oruga/src/utils/helpers.ts
index c9c411307..e7e047de9 100644
--- a/packages/oruga/src/utils/helpers.ts
+++ b/packages/oruga/src/utils/helpers.ts
@@ -89,13 +89,13 @@ export function sortBy(
key: string,
fn?: (a: T, b: T, asc: boolean) => number,
isAsc: boolean = false,
+ mutate: boolean = false,
): T[] {
- let sorted: T[] = [];
// Sorting without mutating original data
if (fn && typeof fn === "function") {
- sorted = [...array].sort((a, b) => fn(a, b, isAsc));
+ return (mutate ? array : [...array]).sort((a, b) => fn(a, b, isAsc));
} else {
- sorted = [...array].sort((a, b) => {
+ return (mutate ? array : [...array]).sort((a, b) => {
// Get nested values from objects
let newA: any = isObject(a) ? getValueByPath(a, key) : a;
let newB: any = isObject(b) ? getValueByPath(b, key) : b;
@@ -115,8 +115,6 @@ export function sortBy(
return isAsc ? (newA > newB ? 1 : -1) : newA > newB ? -1 : 1;
});
}
-
- return sorted;
}
/**
@@ -252,8 +250,10 @@ export function getValueByPath(
path: K,
defaultValue?: DeepType,
): DeepType {
- if (!obj || typeof obj !== "object") return obj as DeepType;
- if (typeof path !== "string") return obj as DeepType;
+ if (!obj || typeof obj !== "object")
+ return defaultValue ?? (obj as DeepType);
+ if (typeof path !== "string")
+ return defaultValue ?? (obj as DeepType);
const value: any = path
.split(".")
diff --git a/packages/oruga/src/utils/icons.ts b/packages/oruga/src/utils/icons.ts
index 1c820315d..d5644bec5 100644
--- a/packages/oruga/src/utils/icons.ts
+++ b/packages/oruga/src/utils/icons.ts
@@ -45,6 +45,7 @@ const faIcons = () => {
"close-circle": "times-circle",
close: "times",
loading: "circle-notch",
+ "emoticon-sad": "frown",
},
};
};