Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/ui/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import type { StorybookConfig } from '@storybook/vue3-vite'
import { mergeConfig } from 'vite'
Expand All @@ -16,7 +16,9 @@ const config: StorybookConfig = {
mergeConfig(config, {
resolve: {
alias: {
'@modrinth/api-client': path.resolve(__dirname, '../../api-client/src/index.ts'),
'@modrinth/api-client': fileURLToPath(
new URL('../../api-client/src/index.ts', import.meta.url),
),
},
},
}),
Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/components/base/buttons/Button.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed, ref } from 'vue'

import ButtonFrame from './ButtonFrame.vue'
import type { ButtonNativeType, ButtonSize, ButtonTone, ButtonVariant } from './types'

const props = withDefaults(
defineProps<{
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
type?: ButtonNativeType
disabled?: boolean
loading?: boolean
}>(),
{
variant: 'base',
size: 'default',
type: 'button',
disabled: false,
loading: false,
},
)

const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
const element = computed(() => frame.value?.element ?? null)

defineExpose({ element })
</script>

<template>
<ButtonFrame
ref="frame"
as="button"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:type="props.type"
:disabled="props.disabled || props.loading"
:aria-busy="props.loading || undefined"
>
<slot />
</ButtonFrame>
</template>
128 changes: 128 additions & 0 deletions packages/ui/src/components/base/buttons/ButtonFrame.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script setup lang="ts">
import type { Component, CSSProperties } from 'vue'
import { computed, ref } from 'vue'

import type { ButtonSize, ButtonTone, ButtonVariant } from './types'

const baseClasses = [
'relative inline-flex min-w-0 shrink-0 touch-manipulation items-center justify-center',
'whitespace-nowrap border-0 no-underline',
'cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out',
'hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]',
'focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow',
'enabled:active:scale-[0.97] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'[&[aria-disabled=true]]:pointer-events-none [&[aria-disabled=true]]:cursor-not-allowed [&[aria-disabled=true]]:opacity-50',
].join(' ')

const sizeClasses: Record<ButtonSize, string> = {
sm: 'h-6 gap-1 rounded-lg px-1.5 text-sm font-semibold leading-5 [&>svg]:size-4 [&>svg]:min-h-4 [&>svg]:min-w-4 [&>svg]:shrink-0',
default:
'h-9 gap-1.5 rounded-xl px-2.5 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
md: 'h-10 gap-2 rounded-[14px] px-4 text-base font-semibold leading-5 [&>svg]:size-5 [&>svg]:min-h-5 [&>svg]:min-w-5 [&>svg]:shrink-0',
lg: 'h-12 gap-2 rounded-2xl px-3.5 text-base font-extrabold leading-5 [&>svg]:size-6 [&>svg]:min-h-6 [&>svg]:min-w-6 [&>svg]:shrink-0',
}

const iconOnlySizeClasses: Record<ButtonSize, string> = {
sm: 'w-6 px-0',
default: 'w-9 px-0',
md: 'w-10 px-0',
lg: 'w-12 px-0',
}

const variantClasses: Record<ButtonVariant, string> = {
base: 'button-frame--base bg-surface-4 text-contrast [&>svg]:text-primary',
colored: 'button-frame--colored bg-[--button-tone] text-[rgba(0,0,0,0.9)] [&>svg]:text-inherit',
outlined: 'button-frame--outlined bg-transparent text-contrast [&>svg]:text-primary',
quiet:
'button-frame--quiet bg-transparent hover:bg-surface-4 focus-visible:bg-surface-4 [&>svg]:text-inherit',
}

const toneVariables: Record<ButtonTone, string> = {
brand: 'var(--color-brand)',
red: 'var(--color-red)',
orange: 'var(--color-orange)',
green: 'var(--color-green)',
blue: 'var(--color-blue)',
purple: 'var(--color-purple)',
promotion: 'var(--medal-promotion-text-orange, var(--color-orange))',
}

const props = withDefaults(
defineProps<{
as: string | Component
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
iconOnly?: boolean
}>(),
{
variant: 'base',
size: 'default',
iconOnly: false,
},
)

const element = ref<HTMLElement | null>(null)
const classes = computed(() => [
baseClasses,
variantClasses[props.variant],
sizeClasses[props.size],
props.iconOnly ? iconOnlySizeClasses[props.size] : '',
])
const style = computed((): CSSProperties | undefined => {
if (props.variant === 'quiet' && !props.tone) return undefined
if (props.variant !== 'colored' && props.variant !== 'quiet') return undefined

return {
'--button-tone': toneVariables[props.tone ?? 'brand'],
} as CSSProperties
})

defineExpose({ element })
</script>

<template>
<component :is="as" ref="element" data-button :class="classes" :style="style">
<slot />
</component>
</template>

<style scoped>
.button-frame--base {
box-shadow:
inset 0 0 0 1px var(--surface-5),
0 1px 1px rgba(0, 0, 0, 0.12);
}

.button-frame--colored {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--button-tone) 30%, transparent),
0 2px 4px rgba(0, 0, 0, 0.04),
0 5px 8px rgba(0, 0, 0, 0.04),
0 10px 18px rgba(0, 0, 0, 0.03),
0 24px 48px rgba(0, 0, 0, 0.03);
}

.button-frame--colored::before {
position: absolute;
inset: 0;
padding: 1px;
pointer-events: none;
content: '';
border-radius: inherit;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0));
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}

.button-frame--outlined {
box-shadow: inset 0 0 0 1px var(--surface-5);
}

.button-frame--quiet {
color: var(--button-tone, var(--color-base));
}
</style>
20 changes: 20 additions & 0 deletions packages/ui/src/components/base/buttons/ButtonGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label?: string
}>(),
{
label: undefined,
},
)
</script>

<template>
<div
role="group"
:aria-label="label"
class="inline-flex [&>[data-button]:focus-visible]:z-10 [&>[data-button]:not(:first-child)]:rounded-l-none [&>[data-button]:not(:last-child)]:rounded-r-none"
>
<slot />
</div>
</template>
63 changes: 63 additions & 0 deletions packages/ui/src/components/base/buttons/ButtonLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, type RouteLocationRaw } from 'vue-router'

import ButtonFrame from './ButtonFrame.vue'
import type { ButtonSize, ButtonTone, ButtonVariant } from './types'

const props = withDefaults(
defineProps<{
to?: RouteLocationRaw
href?: string
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
target?: string
rel?: string
download?: string | boolean
disabled?: boolean
}>(),
{
to: undefined,
href: undefined,
variant: 'base',
size: 'default',
target: undefined,
rel: undefined,
download: undefined,
disabled: false,
},
)

const usesRouter = computed(() => props.to !== undefined && !props.disabled)
const component = computed(() => (usesRouter.value ? RouterLink : 'a'))
const resolvedRel = computed(() => {
if (props.rel) return props.rel
return props.target === '_blank' ? 'noopener noreferrer' : undefined
})

function handleClick(event: MouseEvent) {
if (!props.disabled) return
event.preventDefault()
event.stopImmediatePropagation()
}
</script>

<template>
<ButtonFrame
:as="component"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:to="usesRouter ? props.to : undefined"
:href="!usesRouter && !props.disabled ? props.href : undefined"
:target="props.target"
:rel="resolvedRel"
:download="!props.disabled ? props.download : undefined"
:aria-disabled="props.disabled || undefined"
:tabindex="props.disabled ? -1 : undefined"
@click="handleClick"
>
<slot />
</ButtonFrame>
</template>
83 changes: 83 additions & 0 deletions packages/ui/src/components/base/buttons/FileButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { fileIsValid } from '@modrinth/utils'

import { useFormatBytes } from '../../../composables'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonSize, ButtonTone, ButtonVariant } from './types'

const props = withDefaults(
defineProps<{
prompt?: string
multiple?: boolean
accept?: string
maxSize?: number | null
disabled?: boolean
allowDrop?: boolean
variant?: ButtonVariant
tone?: ButtonTone
size?: ButtonSize
}>(),
{
prompt: 'Select file',
multiple: false,
accept: undefined,
maxSize: undefined,
disabled: false,
allowDrop: true,
variant: 'base',
size: 'default',
},
)

const emit = defineEmits<{
change: [files: File[]]
}>()

const formatBytes = useFormatBytes()

function selectFiles(incoming: FileList) {
if (props.disabled) return

const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
const validFiles = Array.from(incoming).filter((file) =>
fileIsValid(file, validationOptions, formatBytes),
)

if (validFiles.length > 0) emit('change', validFiles)
}

function handleChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) selectFiles(input.files)
input.value = ''
}

function handleDrop(event: DragEvent) {
if (!props.allowDrop || !event.dataTransfer) return
selectFiles(event.dataTransfer.files)
}
</script>

<template>
<ButtonFrame
as="label"
:variant="props.variant"
:tone="props.tone"
:size="props.size"
:aria-disabled="props.disabled || undefined"
class="focus-within:outline-none focus-within:ring-4 focus-within:ring-brand-shadow"
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot />
{{ props.prompt }}
<input
type="file"
:multiple="props.multiple"
:accept="props.accept"
:disabled="props.disabled"
class="absolute size-px overflow-hidden whitespace-nowrap [clip:rect(0,0,0,0)]"
@change="handleChange"
/>
</ButtonFrame>
</template>
Loading
Loading