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
							
								
									22b4c5128b
								
							
						
					
					
						commit
						24fb0cf9ec
					
				|  | @ -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
	
	 jon r
						jon r