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