diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index ede6261144..7653a7b799 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -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' @@ -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), + ), }, }, }), diff --git a/packages/ui/src/components/base/buttons/Button.vue b/packages/ui/src/components/base/buttons/Button.vue new file mode 100644 index 0000000000..a335c4116d --- /dev/null +++ b/packages/ui/src/components/base/buttons/Button.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/ui/src/components/base/buttons/ButtonFrame.vue b/packages/ui/src/components/base/buttons/ButtonFrame.vue new file mode 100644 index 0000000000..82c0987a1a --- /dev/null +++ b/packages/ui/src/components/base/buttons/ButtonFrame.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/ui/src/components/base/buttons/ButtonGroup.vue b/packages/ui/src/components/base/buttons/ButtonGroup.vue new file mode 100644 index 0000000000..6f570a2993 --- /dev/null +++ b/packages/ui/src/components/base/buttons/ButtonGroup.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/ui/src/components/base/buttons/ButtonLink.vue b/packages/ui/src/components/base/buttons/ButtonLink.vue new file mode 100644 index 0000000000..45c9d03b80 --- /dev/null +++ b/packages/ui/src/components/base/buttons/ButtonLink.vue @@ -0,0 +1,63 @@ + + + diff --git a/packages/ui/src/components/base/buttons/FileButton.vue b/packages/ui/src/components/base/buttons/FileButton.vue new file mode 100644 index 0000000000..7903d6d70e --- /dev/null +++ b/packages/ui/src/components/base/buttons/FileButton.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/ui/src/components/base/buttons/IconButton.vue b/packages/ui/src/components/base/buttons/IconButton.vue new file mode 100644 index 0000000000..5f85c6bf0a --- /dev/null +++ b/packages/ui/src/components/base/buttons/IconButton.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/ui/src/components/base/buttons/SplitButton.vue b/packages/ui/src/components/base/buttons/SplitButton.vue new file mode 100644 index 0000000000..027b79d3bd --- /dev/null +++ b/packages/ui/src/components/base/buttons/SplitButton.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue b/packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue new file mode 100644 index 0000000000..6746b1915f --- /dev/null +++ b/packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue @@ -0,0 +1,288 @@ + + + diff --git a/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue b/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue new file mode 100644 index 0000000000..577121caaa --- /dev/null +++ b/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/ui/src/components/base/buttons/index.ts b/packages/ui/src/components/base/buttons/index.ts new file mode 100644 index 0000000000..312391f8af --- /dev/null +++ b/packages/ui/src/components/base/buttons/index.ts @@ -0,0 +1,23 @@ +export { default as Button } from './Button.vue' +export { default as ButtonFrame } from './ButtonFrame.vue' +export { default as ButtonGroup } from './ButtonGroup.vue' +export { default as ButtonLink } from './ButtonLink.vue' +export { default as FileButton } from './FileButton.vue' +export { default as IconButton } from './IconButton.vue' +export { default as SplitButton } from './SplitButton.vue' +export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue' +export { default as TeleportPopoutMenu } from './TeleportPopoutMenu.vue' +export type { + ButtonElementHandle, + ButtonLinkDestination, + ButtonNativeType, + ButtonSize, + ButtonTone, + ButtonVariant, + ButtonVisualProps, + OverflowMenuAction, + OverflowMenuDivider, + OverflowMenuLink, + OverflowMenuOption, + TeleportPlacement, +} from './types' diff --git a/packages/ui/src/components/base/buttons/types.ts b/packages/ui/src/components/base/buttons/types.ts new file mode 100644 index 0000000000..ebfc493a98 --- /dev/null +++ b/packages/ui/src/components/base/buttons/types.ts @@ -0,0 +1,80 @@ +import type { Component } from 'vue' +import type { RouteLocationRaw } from 'vue-router' + +export type ButtonVariant = 'base' | 'colored' | 'outlined' | 'quiet' + +export type ButtonSize = 'sm' | 'default' | 'md' | 'lg' + +export type ButtonTone = 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'promotion' + +export type ButtonVisualProps = { + size?: ButtonSize +} & ( + | { + variant?: 'base' + tone?: never + } + | { + variant: 'outlined' + tone?: never + } + | { + variant: 'colored' + tone?: ButtonTone + } + | { + variant: 'quiet' + tone?: ButtonTone + } +) + +export type ButtonNativeType = 'button' | 'submit' | 'reset' + +export type ButtonLinkDestination = + | { + to: RouteLocationRaw + href?: never + } + | { + href: string + to?: never + } + +export type TeleportPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' + +export interface OverflowMenuItemBase { + id: string + label: string + icon?: Component + shown?: boolean + disabled?: boolean + tooltip?: string + remainOpen?: boolean + tone?: 'default' | 'red' +} + +export interface OverflowMenuAction extends OverflowMenuItemBase { + type?: 'action' + action: (event: MouseEvent) => void +} + +export interface OverflowMenuLink extends OverflowMenuItemBase { + type: 'link' + to?: RouteLocationRaw + href?: string + target?: string + rel?: string + download?: string | boolean +} + +export interface OverflowMenuDivider { + type: 'divider' + id?: string + shown?: boolean +} + +export type OverflowMenuOption = OverflowMenuAction | OverflowMenuLink | OverflowMenuDivider + +export interface ButtonElementHandle { + element: HTMLElement | null +} diff --git a/packages/ui/src/components/base/buttons/useAnchoredTeleport.ts b/packages/ui/src/components/base/buttons/useAnchoredTeleport.ts new file mode 100644 index 0000000000..ce82c3ad2b --- /dev/null +++ b/packages/ui/src/components/base/buttons/useAnchoredTeleport.ts @@ -0,0 +1,107 @@ +import type { CSSProperties, Ref } from 'vue' +import { nextTick, onUnmounted, ref, watch } from 'vue' + +import type { TeleportPlacement } from './types' + +const viewportPadding = 8 + +export function useAnchoredTeleport( + trigger: Readonly>, + panel: Readonly>, + placement: Readonly>, +) { + const isOpen = ref(false) + const panelStyle = ref({ + top: '0px', + left: '0px', + visibility: 'hidden', + }) + + let resizeObserver: ResizeObserver | undefined + + function updatePosition() { + if (!isOpen.value || !trigger.value || !panel.value) return + + const triggerRect = trigger.value.getBoundingClientRect() + const panelRect = panel.value.getBoundingClientRect() + const offset = 8 + const prefersTop = placement.value.startsWith('top') + const alignsEnd = placement.value.endsWith('end') + const spaceBelow = window.innerHeight - triggerRect.bottom - viewportPadding + const spaceAbove = triggerRect.top - viewportPadding + const opensAbove = prefersTop + ? panelRect.height + offset <= spaceAbove || spaceAbove > spaceBelow + : panelRect.height + offset > spaceBelow && spaceAbove > spaceBelow + + const idealTop = opensAbove + ? triggerRect.top - panelRect.height - offset + : triggerRect.bottom + offset + const idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left + const maxTop = Math.max(viewportPadding, window.innerHeight - panelRect.height - viewportPadding) + const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding) + + panelStyle.value = { + top: `${Math.min(Math.max(idealTop, viewportPadding), maxTop)}px`, + left: `${Math.min(Math.max(idealLeft, viewportPadding), maxLeft)}px`, + visibility: 'visible', + } + } + + function handlePointerDown(event: PointerEvent) { + const target = event.target as Node | null + if (!target || trigger.value?.contains(target) || panel.value?.contains(target)) return + close() + } + + function addListeners() { + document.addEventListener('pointerdown', handlePointerDown) + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + + resizeObserver = new ResizeObserver(updatePosition) + if (trigger.value) resizeObserver.observe(trigger.value) + if (panel.value) resizeObserver.observe(panel.value) + } + + function removeListeners() { + document.removeEventListener('pointerdown', handlePointerDown) + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + resizeObserver?.disconnect() + resizeObserver = undefined + } + + async function open() { + if (isOpen.value) return + panelStyle.value = { top: '0px', left: '0px', visibility: 'hidden' } + isOpen.value = true + await nextTick() + updatePosition() + addListeners() + } + + function close(restoreFocus = false) { + if (!isOpen.value) return + isOpen.value = false + removeListeners() + if (restoreFocus) nextTick(() => trigger.value?.focus()) + } + + watch(placement, updatePosition) + watch(panel, () => { + if (!isOpen.value) return + resizeObserver?.disconnect() + if (trigger.value) resizeObserver?.observe(trigger.value) + if (panel.value) resizeObserver?.observe(panel.value) + updatePosition() + }) + + onUnmounted(removeListeners) + + return { + isOpen, + panelStyle, + open, + close, + } +} diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index 7fa6262111..9ce997fb72 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -9,6 +9,20 @@ export { default as BaseTerminal } from './BaseTerminal.vue' export { default as BigOptionButton } from './BigOptionButton.vue' export { default as BulletDivider } from './BulletDivider.vue' export { default as Button } from './Button.vue' +export { default as ButtonGroup } from './buttons/ButtonGroup.vue' +export { default as ButtonLink } from './buttons/ButtonLink.vue' +export type { + ButtonNativeType, + ButtonSize, + ButtonTone, + ButtonVariant, + ButtonVisualProps, + OverflowMenuAction, + OverflowMenuDivider, + OverflowMenuLink, + OverflowMenuOption, + TeleportPlacement, +} from './buttons/types' export { default as ButtonStyled } from './ButtonStyled.vue' export { default as Card } from './Card.vue' export { default as Checkbox } from './Checkbox.vue' @@ -31,6 +45,7 @@ export { default as DropzoneFileInput } from './DropzoneFileInput.vue' export { default as EmptyState } from './EmptyState.vue' export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue' export { default as ErrorInformationCard } from './ErrorInformationCard.vue' +export { default as FileButton } from './buttons/FileButton.vue' export { default as FileInput } from './FileInput.vue' export type { FileTreeSelectItem } from './FileTreeSelect.vue' export { default as FileTreeSelect } from './FileTreeSelect.vue' @@ -44,6 +59,7 @@ export { default as FormattedTag } from './FormattedTag.vue' export { default as HeadingLink } from './HeadingLink.vue' export { default as HorizontalRule } from './HorizontalRule.vue' export { default as I18nDebugPanel } from './I18nDebugPanel.vue' +export { default as IconButton } from './buttons/IconButton.vue' export { default as IconSelect } from './IconSelect.vue' export { default as IntlFormatted } from './IntlFormatted.vue' export type { JoinedButtonAction } from './JoinedButtons.vue' @@ -79,6 +95,7 @@ export { default as SettingsLabel } from './SettingsLabel.vue' export { default as SimpleBadge } from './SimpleBadge.vue' export { default as Slider } from './Slider.vue' export { default as SmartClickable } from './SmartClickable.vue' +export { default as SplitButton } from './buttons/SplitButton.vue' export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmonitions.vue' export { default as StackedAdmonitions } from './StackedAdmonitions.vue' export { default as StatItem } from './StatItem.vue' @@ -89,6 +106,8 @@ export type { TabsTab, TabsValue } from './Tabs.vue' export { default as Tabs } from './Tabs.vue' export { default as TagItem } from './TagItem.vue' export { default as TagTagItem } from './TagTagItem.vue' +export { default as TeleportOverflowMenu } from './buttons/TeleportOverflowMenu.vue' +export { default as TeleportPopoutMenu } from './buttons/TeleportPopoutMenu.vue' export type { TimeFrameLastUnit, TimeFrameLastUnitOption, diff --git a/packages/ui/src/stories/buttons/Button.stories.ts b/packages/ui/src/stories/buttons/Button.stories.ts new file mode 100644 index 0000000000..f08c651977 --- /dev/null +++ b/packages/ui/src/stories/buttons/Button.stories.ts @@ -0,0 +1,208 @@ +import { DownloadIcon, ExternalIcon, HeartIcon, SettingsIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '../../components/base/buttons/Button.vue' +import ButtonLink from '../../components/base/buttons/ButtonLink.vue' +import IconButton from '../../components/base/buttons/IconButton.vue' + +const variants = ['base', 'colored', 'outlined', 'quiet'] as const +const sizes = ['sm', 'default', 'md', 'lg'] as const +const tones = ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'promotion'] as const +const sizeColumns = [ + { value: 'sm', label: 'Small' }, + { value: 'default', label: 'Default' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, +] as const +const variantRows = [ + { label: 'Base', variant: 'base' }, + { label: 'Outlined', variant: 'outlined' }, + { label: 'Quiet', variant: 'quiet' }, + ...tones.map((tone) => ({ + label: `Colored / ${tone.charAt(0).toUpperCase()}${tone.slice(1)}`, + variant: 'colored' as const, + tone, + })), + ...tones.map((tone) => ({ + label: `Quiet / ${tone.charAt(0).toUpperCase()}${tone.slice(1)}`, + variant: 'quiet' as const, + tone, + })), +] + +const meta = { + title: 'Buttons/Button', + component: Button, + argTypes: { + variant: { + control: 'select', + options: variants, + }, + size: { + control: 'select', + options: sizes, + }, + tone: { + control: 'select', + options: tones, + }, + type: { + control: 'select', + options: ['button', 'submit', 'reset'], + }, + disabled: { control: 'boolean' }, + loading: { control: 'boolean' }, + }, + args: { + variant: 'base', + size: 'default', + tone: 'brand', + type: 'button', + disabled: false, + loading: false, + }, + render: (args) => ({ + components: { Button, DownloadIcon }, + setup() { + return { args } + }, + template: /*html*/ ` + + `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const AllVariants: Story = { + render: () => ({ + components: { Button, DownloadIcon }, + setup() { + return { sizeColumns, variantRows } + }, + template: /*html*/ ` +
+
+
+ {{ size.label }} +
+ + +
+ `, + }), +} + +export const Quiet: Story = { + render: () => ({ + components: { Button, DownloadIcon, IconButton, SettingsIcon }, + template: /*html*/ ` +
+ + + +
+ `, + }), +} + +export const Sizes: Story = { + render: () => ({ + components: { Button, DownloadIcon, IconButton }, + setup() { + return { sizes } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} + +export const ColoredTones: Story = { + render: () => ({ + components: { Button }, + setup() { + return { tones } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} + +export const Content: Story = { + render: () => ({ + components: { Button, DownloadIcon, SettingsIcon }, + template: /*html*/ ` +
+ + + + + +
+ `, + }), +} + +export const InteractionStates: Story = { + render: () => ({ + components: { Button }, + template: /*html*/ ` +
+ + + + + + + +
+ `, + }), +} + +export const LinksAndIconButton: Story = { + render: () => ({ + components: { ButtonLink, ExternalIcon, HeartIcon, IconButton }, + template: /*html*/ ` +
+ Internal link + + Modrinth + + Disabled link + + + + +
+ `, + }), +} diff --git a/packages/ui/src/stories/buttons/ButtonGroup.stories.ts b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts new file mode 100644 index 0000000000..3a7b93364a --- /dev/null +++ b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts @@ -0,0 +1,82 @@ +import { PlayIcon, SettingsIcon, StopCircleIcon, TrashIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '../../components/base/buttons/Button.vue' +import ButtonGroup from '../../components/base/buttons/ButtonGroup.vue' +import SplitButton from '../../components/base/buttons/SplitButton.vue' +import type { OverflowMenuOption } from '../../components/base/buttons/types' + +const splitOptions: OverflowMenuOption[] = [ + { + id: 'settings', + label: 'Server settings', + icon: SettingsIcon, + action: () => undefined, + }, + { type: 'divider' }, + { + id: 'delete', + label: 'Delete server', + icon: TrashIcon, + tone: 'red', + action: () => undefined, + }, +] + +const meta = { + title: 'Buttons/Button Group', + component: ButtonGroup, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Joined: Story = { + render: () => ({ + components: { Button, ButtonGroup }, + template: /*html*/ ` + + + + + `, + }), +} + +export const Split: Story = { + render: () => ({ + components: { PlayIcon, SplitButton }, + setup() { + return { splitOptions } + }, + template: /*html*/ ` + + Start server + + `, + }), +} + +export const IndependentDisabledStates: Story = { + render: () => ({ + components: { SplitButton, StopCircleIcon }, + setup() { + return { splitOptions } + }, + template: /*html*/ ` +
+ + Primary disabled + + + Menu disabled + +
+ `, + }), +} diff --git a/packages/ui/src/stories/buttons/FileButton.stories.ts b/packages/ui/src/stories/buttons/FileButton.stories.ts new file mode 100644 index 0000000000..5a744a0cd8 --- /dev/null +++ b/packages/ui/src/stories/buttons/FileButton.stories.ts @@ -0,0 +1,61 @@ +import { UploadIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import FileButton from '../../components/base/buttons/FileButton.vue' + +const meta = { + title: 'Buttons/File Button', + component: FileButton, + argTypes: { + variant: { + control: 'select', + options: ['base', 'colored', 'outlined', 'quiet'], + }, + size: { + control: 'select', + options: ['sm', 'default', 'md', 'lg'], + }, + tone: { + control: 'select', + options: ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'promotion'], + }, + }, + args: { + prompt: 'Select file', + variant: 'base', + size: 'default', + multiple: false, + disabled: false, + }, + render: (args) => ({ + components: { FileButton, UploadIcon }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const MultipleImages: Story = { + args: { + prompt: 'Select images', + accept: 'image/*', + multiple: true, + variant: 'colored', + }, +} + +export const Disabled: Story = { + args: { + disabled: true, + }, +} diff --git a/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts new file mode 100644 index 0000000000..ff10ea991d --- /dev/null +++ b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts @@ -0,0 +1,97 @@ +import { + DownloadIcon, + ExternalIcon, + MoreVerticalIcon, + SettingsIcon, + TrashIcon, +} from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import TeleportOverflowMenu from '../../components/base/buttons/TeleportOverflowMenu.vue' +import type { OverflowMenuOption } from '../../components/base/buttons/types' + +const options: OverflowMenuOption[] = [ + { + id: 'download', + label: 'Download', + icon: DownloadIcon, + action: () => undefined, + }, + { + id: 'settings', + label: 'Project settings', + icon: SettingsIcon, + type: 'link', + to: '/settings', + }, + { + id: 'website', + label: 'Open website', + icon: ExternalIcon, + type: 'link', + href: 'https://modrinth.com', + target: '_blank', + }, + { + id: 'unavailable', + label: 'Unavailable action', + disabled: true, + tooltip: 'This action is currently unavailable', + action: () => undefined, + }, + { type: 'divider' }, + { + id: 'delete', + label: 'Delete project', + icon: TrashIcon, + tone: 'red', + action: () => undefined, + }, +] + +const meta = { + title: 'Buttons/Teleport Overflow Menu', + component: TeleportOverflowMenu, + args: { + label: 'More actions', + options, + variant: 'base', + size: 'default', + placement: 'bottom-end', + disabled: false, + }, + render: (args) => ({ + components: { MoreVerticalIcon, TeleportOverflowMenu }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const ColoredTrigger: Story = { + args: { + variant: 'colored', + }, +} + +export const OutlinedTrigger: Story = { + args: { + variant: 'outlined', + }, +} + +export const QuietTrigger: Story = { + args: { + variant: 'quiet', + }, +} diff --git a/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts new file mode 100644 index 0000000000..d4910437bc --- /dev/null +++ b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts @@ -0,0 +1,50 @@ +import { SettingsIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '../../components/base/buttons/Button.vue' +import TeleportPopoutMenu from '../../components/base/buttons/TeleportPopoutMenu.vue' + +const meta = { + title: 'Buttons/Teleport Popout Menu', + component: TeleportPopoutMenu, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ArbitraryContent: Story = { + render: () => ({ + components: { Button, SettingsIcon, TeleportPopoutMenu }, + template: /*html*/ ` + + + + + + `, + }), +} + +export const IconTrigger: Story = { + render: () => ({ + components: { SettingsIcon, TeleportPopoutMenu }, + template: /*html*/ ` + + + + + `, + }), +}