diff --git a/front/src/composables/alignment.ts b/front/src/composables/alignment.ts new file mode 100644 index 000000000..8351240a4 --- /dev/null +++ b/front/src/composables/alignment.ts @@ -0,0 +1,91 @@ +import type { Entries, Entry, KeysOfUnion } from 'type-fest' +import type { HTMLAttributes } from 'vue' + +export type AlignmentProps = { + alignText?: 'start' | 'center' | 'end' | 'stretch' | 'space-out', + alignSelf?: 'start' | 'center' | 'end' | 'auto' | 'baseline' | 'stretch', + alignItems?: 'start' | 'center' | 'end' | 'auto' | 'baseline' | 'stretch' +} & { + [T in 'center' | 'stretch']?: true +} +export type Key = KeysOfUnion + +const styles = { + center: 'place-content: center center; place-self: center center;', + stretch: 'place-content: stretch stretch; place-self: stretch stretch;', + alignText: (a:AlignmentProps['alignText']) => ({ + start: 'justify-content: flex-start;', + center: 'place-content: center;', + baseline: 'align-items: baseline;', + end: 'justify-content: flex-end;', + stretch: 'place-content: stretch;', + 'space-out': 'place-content: space-between;' + }[a!]), + alignSelf: (a:AlignmentProps['alignSelf']) => ({ + start: 'align-self: flex-start;', + center: 'align-self: center;', + end: 'align-self: flex-end;', + auto: 'align-self: auto;', + baseline: 'align-self: baseline;', + stretch: 'align-self: stretch;' + }[a!]), + alignItems: (a:AlignmentProps['alignSelf']) => ({ + start: 'align-items: flex-start;', + center: 'align-items: center;', + end: 'align-items: flex-end;', + auto: 'align-items: auto;', + baseline: 'align-items: baseline;', + stretch: 'align-items: stretch;' + }[a!]) +} as const + +const getStyle = (props : Partial) => ([key, value]: Entry):string => + ( + typeof styles[key] === 'string' + ? styles[key] + // @ts-expect-error We know that props[key] is a value accepted by styles[key]. The ts compiler is not so smart. + : (styles[key]((key in props && props[key]) ? props[((props[key]), (key))] : value)) + ) + +const merge = (rules: string[]) => (attributes: HTMLAttributes = {}) => + rules.length === 0 + ? attributes + : ({ + ...attributes, + style: rules.join(' ') + ('style' in attributes ? attributes.style + ' ' : '') + }) + +// All keys are exclusive +const conflicts: Set[] = [ + new Set(['center', 'stretch']), + new Set(['alignText']), + new Set(['alignSelf']) +] + +/** + * Add alignment styles to your component. + * Alignments are designed to work both in a grid and flex contexts but may fail in normal context. + * + * (1) Add `& AlignmentProps` to your `Props` type + * (2) Call `v-bind="alignment(props)"` on your component template + * (3) Now your component accepts width props such as `align-text="center"`. + * + * @param props Your component's props (or ...rest props if you have destructured them already) + * @param defaults These props are applied immediately and can be overridden by the user + * @param attributes Optional: To compose width, color, alignment, etc. + * @returns the corresponding `{ style }` object + */ +export const align = >( + props: TProps, + defaults: Partial = {} +) => merge( + ((Object.entries(props) as Entries).reduce( + (acc, [key, value]) => + value && key in styles + ? acc.filter(([accKey, _]) => !conflicts.find(set => set.has(accKey) && set.has(key))) + .concat([[key, value]]) + : acc + , + (Object.entries(defaults)) as Entries> + )).map(getStyle(props)) + ) diff --git a/front/src/composables/color.ts b/front/src/composables/color.ts new file mode 100644 index 000000000..bb8a97438 --- /dev/null +++ b/front/src/composables/color.ts @@ -0,0 +1,119 @@ +import type { KeysOfUnion } from 'type-fest' +import type { HTMLAttributes } from 'vue' + +export type DefaultProps = + | { default?: true } +export type Default = KeysOfUnion + +export type ColorProps = + | { primary?: true } + | { secondary?: true } + | { destructive?: true } +export type Color = KeysOfUnion + +export type PastelProps = + | { red?: true } + | { blue?: true } + | { purple?: true } + | { green?: true } + | { yellow?: true } +export type Pastel = KeysOfUnion + +export type VariantProps = + | { solid?: true } + | { outline?: true } + | { ghost?: true } +export type Variant = KeysOfUnion + +export type InteractiveProps = + | { interactive?: true } +export type Interactive = KeysOfUnion + +export type RaisedProps = + | { raised?: true } +export type Raised = KeysOfUnion + +/* Props to Classes */ + +export type Props = + (DefaultProps | ColorProps | PastelProps) & VariantProps & InteractiveProps & RaisedProps + +export type Key = + KeysOfUnion + +// You can only have one color +const conflicts: Set[] = [ + new Set(['default', 'primary', 'secondary', 'destructive', 'red', 'blue', 'purple', 'green', 'yellow']), + new Set(['solid', 'outline', 'ghost']) +] + +const classes = { + default: 'default', + primary: 'primary', + secondary: 'secondary', + destructive: 'destructive', + red: 'red', + blue: 'blue', + purple: 'purple', + green: 'green', + yellow: 'yellow', + outline: 'outline', + ghost: 'ghost', + solid: 'solid', + raised: 'raised', + interactive: 'interactive' +} satisfies Record + +const getPrecedence = (searchKey:Key | string) => + Object.entries(classes).findIndex(([key, _]) => key === searchKey) + +/** + * @param props A superset of `{ Key? : true }` + * @returns the number of actually applied classes (if there are no defaults) + */ +export const isNoColors = (props: Partial) => + !color(props)().class + +const merge = (classes: string[]) => (attributes: HTMLAttributes = {}) => + classes.length === 0 + ? attributes + : ({ + ...attributes, + class: classes.join(' ') + ('class' in attributes ? attributes.class + ' ' : '') + }) + +/** + * Add color classes to your component. + * Color classes are defined in `colors.scss`. Make sure to implement the correct style there! + * + * (1) Add a subset of `& (DefaultProps | ColorProps | PastelProps) & VariantProps & InteractiveProps & RaisedProps` to your `Props` type + * (2) Call `v-bind="color(props)"` on your component template + * (3) Now your component accepts color props such as `secondary outline raised`. + * + * Composable with `width`, `color`, `alignment`, etc. + * + * @param props Your component's props (or ...rest props if you have destructured them already) + * @param defaults These props are applied immediately and can be overridden by the user + * @returns a function from the resulting attributes the corresponding `class` object + */ +export const color = (props: Partial, defaults?: Key[]) => + merge( + Object.entries(props) + .sort(([a, _], [b, __]) => getPrecedence(a) - getPrecedence(b)) + .reduce( + (acc, [key, value]) => + value && key in classes + ? acc.filter(accKey => !conflicts.find(set => set.has(accKey) && set.has(key as Key))) + .concat([key as Key]) + : acc + , + defaults || [] + ) + ) + +type ColorSelector = + `${Color | Pastel | Default}${'' | ` ${Variant}${'' | ' interactive'}${'' | ' raised'}`}` + +// Convenience function for applying default colors. Prefer using `color` +export const setColors = (color: ColorSelector) => + ({ class: color }) diff --git a/front/src/composables/width.ts b/front/src/composables/width.ts new file mode 100644 index 000000000..47427adbf --- /dev/null +++ b/front/src/composables/width.ts @@ -0,0 +1,95 @@ +import type { Entries, KeysOfUnion } from 'type-fest' +import type { HTMLAttributes } from 'vue' + +export type WidthProps = + | { minContent?: true } + | { iconWidth?: true } + | { tiny?: true } + | { buttonWidth?: true } + | { small?: true } + | { medium?: true } + | { auto?: true } + | { full?: true } + | { grow?: true } + | { width?: string } + | { square?: true } + | { squareSmall?: true } + | { lowHeight?: true } + | { normalHeight?: true } +export type Key = KeysOfUnion + +const widths = { + minContent: 'width: min-content; flex-grow: 0;', + iconWidth: 'width: 40px;', + tiny: 'width: 124px; --grid-column: span 2;', + buttonWidth: 'width: 136px; --grid-column: span 2; flex-grow: 0; min-width: min-content;', + small: 'width: 202px; --grid-column: span 3;', + medium: 'width: 280px; --grid-column: span 4;', + auto: 'width: auto;', + full: 'width: auto; --grid-column: 1 / -1; place-self: stretch;', + grow: 'flex-grow: 1;', + width: (w: string) => `width: ${w}; flex-grow:0;` +} as const + +const sizes = { + squareSmall: 'height: 40px; width: 40px; padding: 4px; justify-content: center;', + square: 'height: 48px; width: 48px; justify-content: center;', + lowHeight: 'height: 40px;', + normalHeight: 'height: 48px;' +} as const + +const styles = { + ...widths, ...sizes +} as const satisfies Record string)> + +// The `lint:tsc` script more errors here than the language server is happy. +// TODO: Fix this Issue: https://dev.funkwhale.audio/funkwhale/funkwhale/-/issues/2437 +const getStyle = (props: Partial) => (key: Key):string => + key in props + ? typeof styles[key] === 'function' + // @ts-expect-error Typescript is hard. Make the typescript compiler understand `key in props` + ? styles[key](props[key]) + : styles[key] as string + : '' + +// All keys are exclusive +const conflicts: Set[] = [ + new Set(Object.keys(widths) as Key[]), + new Set(Object.keys(sizes) as Key[]) +] + +const merge = (rules: string[]) => (attributes: HTMLAttributes = {}) => + rules.length === 0 + ? attributes + : ({ + ...attributes, + style: rules.join(' ') + ('style' in attributes ? ' ' + attributes.style : '') + }) + +/** + * Add a width style to your component. + * Widths are designed to work both in a page-grid context and in a flex or normal context. + * + * (1) Add `& WidthProps` to your `Props` type + * (2) Call `v-bind="width(props)"` on your component template + * (3) Now your component accepts width props such as `small`, `medium`, `stretch`. + * + * @param props Your component's props (or ...rest props if you have destructured them already) + * @param defaults These props are applied immediately and can be overridden by the user + * @param attributes Optional: To compose width, color, alignment, etc. + * @returns the corresponding `{ style }` object + */ +export const width = >( + props: TProps, + defaults: Key[] = [] +) => merge( + (Object.entries(props) as Entries).reduce( + (acc, [key, value]) => + value && key in styles + ? acc.filter(accKey => !conflicts.find(set => set.has(accKey) && set.has(key))) + .concat([key]) + : acc + , + defaults + ).map(getStyle(props)) + )