mirror of
https://github.com/directus/directus.git
synced 2026-02-14 11:34:56 -05:00
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:
@@ -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';
|
||||
|
||||
121
packages/shared/src/composables/use-custom-selection.ts
Normal file
121
packages/shared/src/composables/use-custom-selection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
packages/shared/src/composables/use-element-size.ts
Normal file
38
packages/shared/src/composables/use-element-size.ts
Normal 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 };
|
||||
}
|
||||
272
packages/shared/src/composables/use-groupable.ts
Normal file
272
packages/shared/src/composables/use-groupable.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
39
packages/shared/src/composables/use-size-class.ts
Normal file
39
packages/shared/src/composables/use-size-class.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user