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