Add Components Package (#15094)

* move components without dependencies to packages

* make every components use vue script setup

* move components and utils from shared to @directus/components

* fix imports

* move over some more components

* get rid of unnecessary isEmpty and notEmpty

* move pagination

* fix missing !

* move groupable components

* move text-overflow and useElementSize

* fix icons not being shown

* add first unit tests

* remove capitalizeFirst

* simple cleanup

* add css-var unit test

* move over most other components

* make every component use script setup

* add some more unit tests

* add more tests and burn v-switch to the ground. 🔥

* add checkbox tests

* start with next test

* add storybook

* add more pages to storybook

* add final stories

* fix stories actions

* improve action fix

* cleaning props and adding tests

* unit tests -.-

* add some documentation to components

* Add docs to each prop

* clean storybook paths

* add more unit tests

* apply v-select fix

* update lock file

* small tweaks

* move back to shared

* fix imports

* fix imports

* cleaning

* stories to typescript

* Fix version number

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
This commit is contained in:
Nitwel
2022-09-01 22:07:31 +02:00
committed by GitHub
parent 38fb314950
commit 5fe28db539
282 changed files with 17644 additions and 6081 deletions

View File

@@ -4,3 +4,7 @@ export * from './use-items';
export * from './use-layout';
export * from './use-sync';
export * from './use-system';
export * from './use-size-class';
export * from './use-groupable';
export * from './use-element-size';
export * from './use-custom-selection';

View File

@@ -0,0 +1,121 @@
import { nanoid } from 'nanoid';
import { computed, ComputedRef, Ref, ref, watch } from 'vue';
type UsableCustomSelection = {
otherValue: Ref<string | null>;
usesOtherValue: ComputedRef<boolean>;
};
export function useCustomSelection(
currentValue: Ref<string>,
items: Ref<any[]>,
emit: (event: string | null) => void
): UsableCustomSelection {
const localOtherValue = ref('');
const otherValue = computed({
get() {
return localOtherValue.value || (usesOtherValue.value ? currentValue.value : '');
},
set(newValue: string | null) {
if (newValue === null) {
localOtherValue.value = '';
emit(null);
} else {
localOtherValue.value = newValue;
emit(newValue);
}
},
});
const usesOtherValue = computed(() => {
if (items.value === null) return false;
// Check if set value is one of the existing keys
const values = items.value.map((item) => item.value);
return (
currentValue.value !== null && currentValue.value.length > 0 && values.includes(currentValue.value) === false
);
});
return { otherValue, usesOtherValue };
}
type OtherValue = {
key: string;
value: string;
};
type UsableCustomSelectionMultiple = {
otherValues: Ref<OtherValue[]>;
addOtherValue: (value?: string) => void;
setOtherValue: (key: string, newValue: string | null) => void;
};
export function useCustomSelectionMultiple(
currentValues: Ref<string[]>,
items: Ref<any[]>,
emit: (event: string[] | null) => void
): UsableCustomSelectionMultiple {
const otherValues = ref<OtherValue[]>([]);
watch(currentValues, (newValue) => {
if (newValue === null) return;
if (Array.isArray(newValue) === false) return;
if (items.value === null) return;
(newValue as string[]).forEach((value) => {
if (items.value === null) return;
const values = items.value.map((item) => item.value);
const existsInValues = values.includes(value) === true;
if (existsInValues === false) {
const other = otherValues.value.map((o) => o.value);
const existsInOtherValues = other.includes(value) === true;
if (existsInOtherValues === false) {
addOtherValue(value);
}
}
});
});
return { otherValues, addOtherValue, setOtherValue };
function addOtherValue(value = '') {
otherValues.value = [
...otherValues.value,
{
key: nanoid(),
value: value,
},
];
}
function setOtherValue(key: string, newValue: string | null) {
const previousValue = otherValues.value.find((o) => o.key === key);
const valueWithoutPrevious = ((currentValues.value || []) as string[]).filter(
(val) => val !== previousValue?.value
);
if (newValue === null) {
otherValues.value = otherValues.value.filter((o) => o.key !== key);
if (valueWithoutPrevious.length === 0) {
emit(null);
} else {
emit(valueWithoutPrevious);
}
} else {
otherValues.value = otherValues.value.map((otherValue) => {
if (otherValue.key === key) otherValue.value = newValue;
return otherValue;
});
const newEmitValue = [...valueWithoutPrevious, newValue];
emit(newEmitValue);
}
}
}

View File

@@ -0,0 +1,38 @@
import { isNil } from 'lodash';
import { isRef, onMounted, onUnmounted, Ref, ref } from 'vue';
declare global {
interface Window {
ResizeObserver: any;
}
}
export function useElementSize<T extends Element>(
target: T | Ref<T> | Ref<undefined>
): {
width: Ref<number>;
height: Ref<number>;
} {
const width = ref(0);
const height = ref(0);
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry === undefined) return;
width.value = entry.contentRect.width;
height.value = entry.contentRect.height;
});
onMounted(() => {
const t = isRef(target) ? target.value : target;
if (!isNil(t)) {
resizeObserver.observe(t);
}
});
onUnmounted(() => {
resizeObserver.disconnect();
});
return { width, height };
}

View File

@@ -0,0 +1,272 @@
import { isEqual, isNil } from 'lodash';
import { computed, inject, nextTick, onBeforeUnmount, provide, ref, shallowRef, Ref, watch } from 'vue';
export type GroupableInstance = {
active: Ref<boolean>;
value: string | number | undefined;
};
/**
* Used to make child item part of the group context. Needs to be used in a component that is a child
* of a component that has the `useGroupableParent` composition enabled
*/
type GroupableOptions = {
value?: string | number;
group?: string;
active?: Ref<boolean>;
watch?: boolean;
};
type UsableGroupable = {
active: Ref<boolean>;
toggle: () => void;
activate: () => void;
deactivate: () => void;
};
export function useGroupable(options?: GroupableOptions): UsableGroupable {
// Injects the registration / toggle functions from the parent scope
const parentFunctions = inject(options?.group || 'item-group', null);
if (isNil(parentFunctions)) {
return {
active: ref(false),
toggle: () => {
// Do nothing
},
activate: () => {
// Do nothing
},
deactivate: () => {
// Do nothing
},
};
}
const {
register,
unregister,
toggle,
selection,
}: {
register: (item: GroupableInstance) => void;
unregister: (item: GroupableInstance) => void;
toggle: (item: GroupableInstance) => void;
selection: Ref<(number | string)[]>;
} = parentFunctions;
let startActive = false;
if (options?.active?.value === true) startActive = true;
if (options?.value && selection.value.includes(options.value)) startActive = true;
const active = ref(startActive);
const item = { active, value: options?.value };
register(item);
if (options?.active !== undefined && options.watch === true) {
watch(options.active, () => {
if (options.active === undefined) return;
if (options.active.value === true) {
if (active.value === false) toggle(item);
active.value = true;
}
if (options.active.value === false) {
if (active.value === true) toggle(item);
active.value = false;
}
});
}
onBeforeUnmount(() => unregister(item));
return {
active,
toggle: () => {
toggle(item);
},
activate: () => {
if (active.value === false) toggle(item);
},
deactivate: () => {
if (active.value === true) toggle(item);
},
};
}
type GroupableParentState = {
selection?: Ref<(string | number)[] | undefined> | Ref<readonly (string | number)[] | undefined>;
onSelectionChange?: (newSelectionValues: readonly (string | number)[]) => void;
onToggle?: (item: GroupableInstance) => void;
};
type GroupableParentOptions = {
mandatory?: Ref<boolean>;
max?: Ref<number>;
multiple?: Ref<boolean>;
};
type UsableGroupableParent = {
items: Ref<GroupableInstance[]>;
selection: Ref<readonly (string | number)[]>;
internalSelection: Ref<(string | number)[]>;
getValueForItem: (item: GroupableInstance) => string | number;
updateChildren: () => void;
};
/**
* Used to make a component a group parent component. Provides the registration / toggle functions
* to its group children
*/
export function useGroupableParent(
state: GroupableParentState = {},
options: GroupableParentOptions = {},
group = 'item-group'
): UsableGroupableParent {
// References to the active state and value of the individual child items
const items = shallowRef<GroupableInstance[]>([]);
// Internal copy of the selection. This allows the composition to work without the state option
// being passed
const internalSelection = ref<(number | string)[]>([]);
// Uses either the internal state, or the passed in state. Will call the onSelectionChange
// handler if it's passed
const selection = computed<readonly (number | string)[]>({
get() {
if (!isNil(state.selection) && !isNil(state.selection.value)) {
return state.selection.value;
}
return internalSelection.value;
},
set(newSelection) {
if (!isNil(state.onSelectionChange)) {
state.onSelectionChange(newSelection);
}
internalSelection.value = [...newSelection];
},
});
// Provide the needed functions to all children groupable components. Note: nested item groups
// will override the item-group namespace, making nested item groups possible.
provide(group, { register, unregister, toggle, selection });
// Whenever the value of the selection changes, we have to update all the children's internal
// states. If not, you can have an activated item that's not actually active.
watch(selection, updateChildren, { immediate: true });
// It takes a tick before all children are rendered, this will make sure the start state of the
// children matches the start selection
nextTick().then(updateChildren);
watch(
() => options?.mandatory?.value,
(newValue, oldValue) => {
if (isEqual(newValue, oldValue)) return;
// If you're required to select a value, make sure a value is selected on first render
if (!selection.value || (selection.value.length === 0 && options?.mandatory?.value === true)) {
if (items.value[0]) selection.value = [getValueForItem(items.value[0])];
}
}
);
// These aren't exported with any particular use in mind. It's mostly for testing purposes.
// Treat them as readonly.
return { items, selection, internalSelection, getValueForItem, updateChildren };
// Register a child within the context of this group
function register(item: GroupableInstance) {
items.value = [...items.value, item];
const value = getValueForItem(item);
// If you're required to select a value, make sure a value is selected on first render
if (selection.value.length === 0 && options?.mandatory?.value === true && items.value.length === 1) {
selection.value = [value];
}
if (item.active.value && selection.value.includes(value) === false) {
toggle(item);
}
}
// Remove a child within the context of this group. Needed to avoid memory leaks.
function unregister(item: GroupableInstance) {
items.value = items.value.filter((existingItem: any) => {
return existingItem !== item;
});
}
// Toggle the active state for the given item
function toggle(item: GroupableInstance) {
if (options?.multiple?.value === true) {
toggleMultiple(item);
} else {
toggleSingle(item);
}
if (!isNil(state.onToggle)) {
state.onToggle(item);
}
}
function toggleSingle(item: GroupableInstance) {
const itemValue = getValueForItem(item);
if (selection.value[0] === itemValue && options?.mandatory?.value !== true) {
selection.value = [];
return;
}
if (selection.value[0] !== itemValue) {
selection.value = [itemValue];
}
}
function toggleMultiple(item: GroupableInstance) {
const itemValue = getValueForItem(item);
// Remove the item if it is already selected. Don't remove it if it's the last item and
// the mandatory option is set
if (selection.value.includes(itemValue)) {
if (options?.mandatory?.value === true && selection.value.length === 1) {
updateChildren();
return;
}
selection.value = selection.value.filter((value) => value !== itemValue);
return;
}
// Don't add it if when we're already at the maximum number of selections
if (options?.max?.value && options.max.value !== -1 && selection.value.length >= options.max.value) {
// Even though we don't alter selection, we should flush the internal active state of
// the children to make sure we don't have any invalid internal active states
updateChildren();
return;
}
// Add the selected item to the selection
selection.value = [...selection.value, itemValue];
}
// Converts the item reference into the value that's used in the selection. This value is either
// the index of the item in the items array (by default), or the custom value that's passed in
// the groupable composition
function getValueForItem(item: GroupableInstance) {
return item.value || items.value.findIndex((child) => item === child);
}
// Loop over all children and make sure their internal active state matches the selection array
// of the parent
function updateChildren() {
items.value.forEach((item) => {
item.active.value = selection.value.includes(getValueForItem(item));
});
}
}

View File

@@ -1,11 +1,10 @@
import { useApi } from './use-system';
import axios, { CancelTokenSource } from 'axios';
import { useCollection } from './use-collection';
import { Item } from '../types';
import { Item, Query } from '../types';
import { moveInArray } from '../utils';
import { isEqual, throttle } from 'lodash';
import { computed, ComputedRef, ref, Ref, watch, WritableComputedRef, unref } from 'vue';
import { Query } from '../types/query';
type ManualSortData = {
item: string | number;

View File

@@ -0,0 +1,39 @@
import { computed, ComputedRef } from 'vue';
export const sizeProps = {
xSmall: {
type: Boolean,
default: false,
},
small: {
type: Boolean,
default: false,
},
large: {
type: Boolean,
default: false,
},
xLarge: {
type: Boolean,
default: false,
},
};
interface SizeProps {
xSmall?: boolean;
small?: boolean;
large?: boolean;
xLarge?: boolean;
}
export function useSizeClass<T>(props: T & SizeProps): ComputedRef<string | null> {
const sizeClass = computed<string | null>(() => {
if (props.xSmall) return 'x-small';
if (props.small) return 'small';
if (props.large) return 'large';
if (props.xLarge) return 'x-large';
return null;
});
return sizeClass;
}