Feat(front): define UI design tokens and utilities
Co-Authored-By: ArneBo <arne@ecobasa.org> Co-Authored-By: Flupsi <upsiflu@gmail.com> Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
		
							parent
							
								
									e2720007be
								
							
						
					
					
						commit
						0026b6b741
					
				| 
						 | 
				
			
			@ -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<AlignmentProps>
 | 
			
		||||
 | 
			
		||||
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<AlignmentProps>) => ([key, value]: Entry<AlignmentProps>):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<Key>[] = [
 | 
			
		||||
  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 = <TProps extends Partial<AlignmentProps>>(
 | 
			
		||||
  props: TProps,
 | 
			
		||||
  defaults: Partial<AlignmentProps> = {}
 | 
			
		||||
) => merge(
 | 
			
		||||
    ((Object.entries(props) as Entries<TProps>).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<Partial<AlignmentProps>>
 | 
			
		||||
    )).map(getStyle(props))
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,119 @@
 | 
			
		|||
import type { KeysOfUnion } from 'type-fest'
 | 
			
		||||
import type { HTMLAttributes } from 'vue'
 | 
			
		||||
 | 
			
		||||
export type DefaultProps =
 | 
			
		||||
  | { default?: true }
 | 
			
		||||
export type Default = KeysOfUnion<DefaultProps>
 | 
			
		||||
 | 
			
		||||
export type ColorProps =
 | 
			
		||||
  | { primary?: true }
 | 
			
		||||
  | { secondary?: true }
 | 
			
		||||
  | { destructive?: true }
 | 
			
		||||
export type Color = KeysOfUnion<ColorProps>
 | 
			
		||||
 | 
			
		||||
export type PastelProps =
 | 
			
		||||
  | { red?: true }
 | 
			
		||||
  | { blue?: true }
 | 
			
		||||
  | { purple?: true }
 | 
			
		||||
  | { green?: true }
 | 
			
		||||
  | { yellow?: true }
 | 
			
		||||
export type Pastel = KeysOfUnion<PastelProps>
 | 
			
		||||
 | 
			
		||||
export type VariantProps =
 | 
			
		||||
  | { solid?: true }
 | 
			
		||||
  | { outline?: true }
 | 
			
		||||
  | { ghost?: true }
 | 
			
		||||
export type Variant = KeysOfUnion<VariantProps>
 | 
			
		||||
 | 
			
		||||
export type InteractiveProps =
 | 
			
		||||
  | { interactive?: true }
 | 
			
		||||
export type Interactive = KeysOfUnion<DefaultProps>
 | 
			
		||||
 | 
			
		||||
export type RaisedProps =
 | 
			
		||||
  | { raised?: true }
 | 
			
		||||
export type Raised = KeysOfUnion<RaisedProps>
 | 
			
		||||
 | 
			
		||||
/* Props to Classes */
 | 
			
		||||
 | 
			
		||||
export type Props =
 | 
			
		||||
  (DefaultProps | ColorProps | PastelProps) & VariantProps & InteractiveProps & RaisedProps
 | 
			
		||||
 | 
			
		||||
export type Key =
 | 
			
		||||
  KeysOfUnion<Props>
 | 
			
		||||
 | 
			
		||||
// You can only have one color
 | 
			
		||||
const conflicts: Set<Key>[] = [
 | 
			
		||||
  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<Key, string>
 | 
			
		||||
 | 
			
		||||
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<Props>) =>
 | 
			
		||||
  !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<Props>, 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 })
 | 
			
		||||
| 
						 | 
				
			
			@ -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<WidthProps>
 | 
			
		||||
 | 
			
		||||
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<Key, string | ((w: string) => 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<WidthProps>) => (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<Key>[] = [
 | 
			
		||||
  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 = <TProps extends Partial<WidthProps>>(
 | 
			
		||||
  props: TProps,
 | 
			
		||||
  defaults: Key[] = []
 | 
			
		||||
) => merge(
 | 
			
		||||
    (Object.entries(props) as Entries<TProps>).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))
 | 
			
		||||
  )
 | 
			
		||||
		Loading…
	
		Reference in New Issue