From 72fa9dd131005525ed7e86deb41908deb9b0a830 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 16 Jul 2026 15:26:16 +0100 Subject: [PATCH 01/29] feat: refactor button components --- packages/ui/.storybook/main.ts | 6 +- .../ui/src/components/base/buttons/Button.vue | 44 +++ .../components/base/buttons/ButtonFrame.vue | 128 ++++++++ .../components/base/buttons/ButtonGroup.vue | 20 ++ .../components/base/buttons/ButtonLink.vue | 63 ++++ .../components/base/buttons/FileButton.vue | 83 +++++ .../components/base/buttons/IconButton.vue | 47 +++ .../components/base/buttons/SplitButton.vue | 83 +++++ .../base/buttons/TeleportOverflowMenu.vue | 288 ++++++++++++++++++ .../base/buttons/TeleportPopoutMenu.vue | 138 +++++++++ .../ui/src/components/base/buttons/index.ts | 23 ++ .../ui/src/components/base/buttons/types.ts | 80 +++++ .../base/buttons/useAnchoredTeleport.ts | 107 +++++++ packages/ui/src/components/base/index.ts | 19 ++ .../ui/src/stories/buttons/Button.stories.ts | 208 +++++++++++++ .../stories/buttons/ButtonGroup.stories.ts | 82 +++++ .../src/stories/buttons/FileButton.stories.ts | 61 ++++ .../buttons/TeleportOverflowMenu.stories.ts | 97 ++++++ .../buttons/TeleportPopoutMenu.stories.ts | 50 +++ 19 files changed, 1625 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/base/buttons/Button.vue create mode 100644 packages/ui/src/components/base/buttons/ButtonFrame.vue create mode 100644 packages/ui/src/components/base/buttons/ButtonGroup.vue create mode 100644 packages/ui/src/components/base/buttons/ButtonLink.vue create mode 100644 packages/ui/src/components/base/buttons/FileButton.vue create mode 100644 packages/ui/src/components/base/buttons/IconButton.vue create mode 100644 packages/ui/src/components/base/buttons/SplitButton.vue create mode 100644 packages/ui/src/components/base/buttons/TeleportOverflowMenu.vue create mode 100644 packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue create mode 100644 packages/ui/src/components/base/buttons/index.ts create mode 100644 packages/ui/src/components/base/buttons/types.ts create mode 100644 packages/ui/src/components/base/buttons/useAnchoredTeleport.ts create mode 100644 packages/ui/src/stories/buttons/Button.stories.ts create mode 100644 packages/ui/src/stories/buttons/ButtonGroup.stories.ts create mode 100644 packages/ui/src/stories/buttons/FileButton.stories.ts create mode 100644 packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts create mode 100644 packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts 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*/ ` + + + + + `, + }), +} From e1c33ad2da1a8cf8a29a0bedb420f590d29af872 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 29 Jul 2026 14:05:32 +0100 Subject: [PATCH 02/29] fix: qa --- .../ui/src/components/base/buttons/Button.vue | 18 ++-- .../components/base/buttons/ButtonFrame.vue | 54 +++++++----- .../components/base/buttons/ButtonLink.vue | 14 ++-- .../components/base/buttons/FileButton.vue | 12 +-- .../components/base/buttons/IconButton.vue | 18 ++-- .../components/base/buttons/SplitButton.vue | 24 +++--- .../base/buttons/TeleportOverflowMenu.vue | 16 ++-- .../base/buttons/TeleportPopoutMenu.vue | 16 ++-- .../ui/src/components/base/buttons/index.ts | 4 +- .../ui/src/components/base/buttons/types.ts | 32 ++++--- packages/ui/src/components/base/index.ts | 14 ++-- .../ui/src/stories/buttons/Button.stories.ts | 84 +++++++++---------- .../stories/buttons/ButtonGroup.stories.ts | 6 +- .../src/stories/buttons/FileButton.stories.ts | 10 +-- .../buttons/TeleportOverflowMenu.stories.ts | 8 +- .../buttons/TeleportPopoutMenu.stories.ts | 4 +- packages/ui/src/utils/index.ts | 1 + .../use-anchored-teleport.ts} | 9 +- 18 files changed, 186 insertions(+), 158 deletions(-) rename packages/ui/src/{components/base/buttons/useAnchoredTeleport.ts => utils/use-anchored-teleport.ts} (92%) diff --git a/packages/ui/src/components/base/buttons/Button.vue b/packages/ui/src/components/base/buttons/Button.vue index a335c4116d..ae374cdcb7 100644 --- a/packages/ui/src/components/base/buttons/Button.vue +++ b/packages/ui/src/components/base/buttons/Button.vue @@ -2,21 +2,21 @@ import { computed, ref } from 'vue' import ButtonFrame from './ButtonFrame.vue' -import type { ButtonNativeType, ButtonSize, ButtonTone, ButtonVariant } from './types' +import type { ButtonColor, ButtonNativeType, ButtonSize, ButtonType } from './types' const props = withDefaults( defineProps<{ - variant?: ButtonVariant - tone?: ButtonTone + type?: ButtonType + color?: ButtonColor size?: ButtonSize - type?: ButtonNativeType + nativeType?: ButtonNativeType disabled?: boolean loading?: boolean }>(), { - variant: 'base', + type: 'base', size: 'default', - type: 'button', + nativeType: 'button', disabled: false, loading: false, }, @@ -32,10 +32,10 @@ defineExpose({ element }) diff --git a/packages/ui/src/components/base/buttons/ButtonFrame.vue b/packages/ui/src/components/base/buttons/ButtonFrame.vue index 82c0987a1a..42d41450c5 100644 --- a/packages/ui/src/components/base/buttons/ButtonFrame.vue +++ b/packages/ui/src/components/base/buttons/ButtonFrame.vue @@ -2,15 +2,20 @@ import type { Component, CSSProperties } from 'vue' import { computed, ref } from 'vue' -import type { ButtonSize, ButtonTone, ButtonVariant } from './types' +import type { ButtonColor, ButtonNativeType, ButtonSize, ButtonType } from './types' const baseClasses = [ - 'relative inline-flex min-w-0 shrink-0 touch-manipulation items-center justify-center', + // Base + 'relative inline-flex min-w-0 shrink-0 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', + // Interactions + 'touch-manipulation cursor-pointer select-none transition-[background-color,color,box-shadow,filter,opacity,transform] duration-150 ease-out', + 'enabled:active:scale-[0.97]', + // Hovering + 'hover:brightness-[--hover-brightness]', + // Accessibility + 'focus-visible:brightness-[--hover-brightness] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow', + '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(' ') @@ -29,52 +34,54 @@ const iconOnlySizeClasses: Record = { lg: 'w-12 px-0', } -const variantClasses: Record = { +const typeClasses: Record = { 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', + colored: 'button-frame--colored bg-[--button-color] 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 = { +const colorVariables: Record = { 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))', + medal_promotion: 'var(--medal-promotion-text-orange, var(--color-orange))', } const props = withDefaults( defineProps<{ as: string | Component - variant?: ButtonVariant - tone?: ButtonTone + type?: ButtonType + color?: ButtonColor size?: ButtonSize iconOnly?: boolean + nativeType?: ButtonNativeType }>(), { - variant: 'base', + type: 'base', size: 'default', iconOnly: false, + nativeType: undefined, }, ) const element = ref(null) const classes = computed(() => [ baseClasses, - variantClasses[props.variant], + typeClasses[props.type], 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 + if (props.type === 'quiet' && !props.color) return undefined + if (props.type !== 'colored' && props.type !== 'quiet') return undefined return { - '--button-tone': toneVariables[props.tone ?? 'brand'], + '--button-color': colorVariables[props.color ?? 'brand'], } as CSSProperties }) @@ -82,7 +89,14 @@ defineExpose({ element }) @@ -96,7 +110,7 @@ defineExpose({ element }) .button-frame--colored { box-shadow: - 0 0 0 1px color-mix(in srgb, var(--button-tone) 30%, transparent), + 0 0 0 1px color-mix(in srgb, var(--button-color) 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), @@ -123,6 +137,6 @@ defineExpose({ element }) } .button-frame--quiet { - color: var(--button-tone, var(--color-base)); + color: var(--button-color, var(--color-base)); } diff --git a/packages/ui/src/components/base/buttons/ButtonLink.vue b/packages/ui/src/components/base/buttons/ButtonLink.vue index 45c9d03b80..9dac0df517 100644 --- a/packages/ui/src/components/base/buttons/ButtonLink.vue +++ b/packages/ui/src/components/base/buttons/ButtonLink.vue @@ -1,16 +1,16 @@ + + +``` + +Use `primary-disabled` and `menu-disabled` when only one side is unavailable. Use +`disabled` when the entire split button is unavailable. + +## Overflow menus + +`TeleportOverflowMenu` is for a menu of discrete actions and navigation targets. Define +its options as `OverflowMenuOption[]`: + +```ts +const options: OverflowMenuOption[] = [ + { + id: 'download', + label: 'Download', + icon: DownloadIcon, + action: download, + }, + { + id: 'project-page', + label: 'Open project page', + icon: ExternalIcon, + type: 'link', + to: projectRoute, + }, + { type: 'divider' }, + { + id: 'delete', + label: 'Delete', + icon: TrashIcon, + tone: 'red', + action: remove, + }, +] +``` + +Each non-divider option needs a stable, unique `id` and a localized `label`. + +- Use an action option for behaviour and a link option for navigation. +- Use `shown: false` to remove an option conditionally. +- Use `disabled` with a `tooltip` when the user needs to understand why an option is + unavailable. +- Use `remainOpen` only when selecting the option should not dismiss the menu. +- Use `tone: 'red'` for destructive options. +- Use dividers sparingly to separate meaningful groups. + +The component implements menu keyboard navigation, focus management, typeahead, and +Escape handling. Consumers should not add competing keyboard or focus behaviour. + +## Popout menus + +Use `TeleportPopoutMenu` for arbitrary interactive content that belongs to a button, +such as a compact settings panel. Use `TeleportOverflowMenu` instead when the content is +only a list of actions or links. + +```vue + + + + + +``` + +Set `icon-only` when the trigger has no visible text; `label` then becomes the trigger's +accessible name. The panel role defaults to `dialog`; use `panel-role="region"` only +when the content is non-modal supplementary content. + +The panel slot receives `close`, and the component manages initial focus, Escape, and +focus restoration. + +## Legacy components + +Do not introduce new uses of the legacy `packages/ui/src/components/base/Button.vue` or +`ButtonStyled.vue` APIs. When migrating: + +| Legacy pattern | Replacement | +| ------------------------------- | -------------------------------------------- | +| `link` / `external` | `ButtonLink` with `to`, `href`, and `target` | +| `action` | `@click` on `Button` | +| `icon-only` | `IconButton` with `label` | +| `large` | `size="lg"` or `size="xl"` | +| `outline` | `type="outlined"` | +| `transparent` | `type="quiet"` | +| `ButtonStyled` wrapping an element | The matching direct button component | + +Preserve the original element's semantics while migrating. A visual match is not enough +if a link becomes a button or an icon-only action loses its accessible name. From f7ed9e74e05b0e27491c1606f38112b4ece54699 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 31 Jul 2026 10:43:14 +0100 Subject: [PATCH 09/29] refactor part 1 --- apps/app-frontend/src/App.vue | 65 +-- .../src/components/ui/AccountsCard.vue | 32 +- .../src/components/ui/AddContentButton.vue | 61 +-- .../src/components/ui/AppActionBar.vue | 24 +- .../src/components/ui/ErrorModal.vue | 30 +- .../src/components/ui/ExportModal.vue | 14 +- .../src/components/ui/Instance.vue | 128 +++--- .../src/components/ui/InstanceIndicator.vue | 9 +- .../src/components/ui/JavaDetectionModal.vue | 20 +- .../src/components/ui/JavaSelector.vue | 43 +- .../src/components/ui/ModpackVersionModal.vue | 33 +- .../src/components/ui/SurveyPopup.vue | 15 +- .../src/components/ui/URLConfirmModal.vue | 7 +- .../src/components/ui/WindowControls.vue | 55 ++- .../components/ui/app-update-button/index.vue | 10 +- .../src/components/ui/friends/FriendsList.vue | 65 ++- .../components/ui/friends/FriendsSection.vue | 47 +-- .../install_flow/AddServerToInstanceModal.vue | 12 +- .../ui/instance-page-header/index.vue | 97 ++--- ...ance-admonitions-shared-instance-stale.vue | 9 +- ...dmonitions-shared-instance-unavailable.vue | 9 +- ...tions-shared-instance-update-available.vue | 9 +- .../ui/instance_settings/GeneralSettings.vue | 20 +- .../ui/instance_settings/JavaSettings.vue | 33 +- .../ui/instance_settings/SharingSettings.vue | 11 +- .../MinecraftAuthErrorModal.vue | 37 +- .../MinecraftRequiredModal.vue | 19 +- .../ui/modal/ConfirmDeleteInstanceModal.vue | 22 +- .../ui/modal/InstallToPlayModal.vue | 38 +- .../ui/modal/ModpackAlreadyInstalledModal.vue | 32 +- .../ui/modal/ModrinthAccountRequiredModal.vue | 28 +- .../ui/settings/account/PrivacySettings.vue | 8 +- .../settings/display/FeatureFlagSettings.vue | 9 +- .../instances/ResourceManagementSettings.vue | 9 +- ...ConfirmRevokeSharedInstanceInviteModal.vue | 22 +- .../SharedInstanceAlreadyInstalledModal.vue | 32 +- ...edInstanceInstallationSettingsControls.vue | 47 +-- .../shared-instance-install-modal/index.vue | 44 +- .../shared-instance-install-summary.vue | 9 +- .../src/components/ui/skin/EditSkinModal.vue | 20 +- .../ui/skin/VirtualSkinSectionList.vue | 35 +- .../src/components/ui/world/InstanceItem.vue | 70 ++- .../src/components/ui/world/WorldItem.vue | 104 ++--- .../ui/world/modal/AddServerModal.vue | 20 +- .../world/modal/ConfirmRemoveWorldModal.vue | 27 +- .../ui/world/modal/EditServerModal.vue | 14 +- .../modal/EditSingleplayerWorldModal.vue | 20 +- apps/app-frontend/src/pages/Skins.vue | 20 +- .../src/pages/instance/Worlds.vue | 32 +- .../src/pages/instance/share/index.vue | 16 +- .../share/shared-instance-members-table.vue | 37 +- .../shared-instance-remove-member-modal.vue | 18 +- apps/app-frontend/src/pages/library/Index.vue | 15 +- .../src/pages/project/Gallery.vue | 83 ++-- apps/app-frontend/src/pages/project/Index.vue | 88 ++-- .../src/pages/project/Version.vue | 75 ++-- .../src/pages/project/Versions.vue | 75 ++-- .../AnalyticsChartRenderLimitModal.vue | 19 +- .../analytics-table/index.vue | 32 +- .../components/analytics-dashboard/index.vue | 37 +- .../query-builder/QueryFilter.vue | 5 +- .../query-builder/index.vue | 26 +- .../stat-cards/StatCards.vue | 18 +- apps/frontend/src/components/ui/Accordion.vue | 24 +- .../src/components/ui/AuthorizationCard.vue | 16 +- .../components/ui/ConfirmTransferOrgModal.vue | 28 +- .../ui/ConfirmTransferProjectModal.vue | 28 +- .../src/components/ui/ModrinthFooter.vue | 41 +- .../src/components/ui/NewsletterButton.vue | 26 +- .../components/ui/OrganizationPageHeader.vue | 41 +- .../ui/OrganizationProjectTransferModal.vue | 45 +- .../ui/ProjectCollectionSaveButton.vue | 97 +++-- .../CompatibleVersionCard.vue | 39 +- .../DownloadDependency.vue | 43 +- .../InstallWithModrinthApp.vue | 28 +- .../ui/ProjectDownloadModal/index.vue | 92 ++-- .../src/components/ui/ProjectMemberHeader.vue | 22 +- .../src/components/ui/ShareArticleButtons.vue | 134 +++--- .../ui/SharedInstanceInviteOpenInAppModal.vue | 34 +- .../ui/admin/AdminBillingChargeCard.vue | 36 +- .../components/ui/admin/AssignNoticeModal.vue | 32 +- .../components/ui/admin/BatchCreditModal.vue | 32 +- .../src/components/ui/admin/TransferModal.vue | 47 +-- .../src/components/ui/auth/CreateAccount.vue | 22 +- .../components/ui/auth/PasskeySettings.vue | 132 +++--- .../src/components/ui/auth/SignIn.vue | 102 +++-- .../src/components/ui/auth/SignUp.vue | 124 +++--- .../ui/banner/GeneratedStateErrorsBanner.vue | 23 +- .../components/ui/banner/PreviewBanner.vue | 14 +- .../src/components/ui/banner/RussiaBanner.vue | 44 +- .../components/ui/banner/StagingBanner.vue | 14 +- .../SubscriptionPaymentFailedBanner.vue | 12 +- .../ui/banner/TaxComplianceBanner.vue | 9 +- .../ui/banner/TaxIdMismatchBanner.vue | 18 +- .../ui/banner/VerifyEmailBanner.vue | 19 +- .../components/AddedDependencyRow.vue | 10 +- .../components/McVersionPicker.vue | 44 +- .../SuggestedDependency.vue | 10 +- .../components/VersionFileRow.vue | 56 +-- .../stages/LoadersStage.vue | 7 +- .../stages/McVersionsStage.vue | 7 +- .../stages/MetadataStage.vue | 82 ++-- .../ui/create/CollectionCreateModal.vue | 22 +- .../components/ui/create/CreateLimitAlert.vue | 30 +- .../ui/create/OrganizationCreateModal.vue | 27 +- .../ui/create/ProjectCreateModal.vue | 28 +- .../ui/dashboard/CreatorTaxFormModal.vue | 40 +- .../ui/dashboard/CreatorWithdrawModal.vue | 67 +-- .../ui/dashboard/RevenueInputField.vue | 10 +- .../ui/dashboard/RevenueTransaction.vue | 18 +- .../LegacyPaypalDetailsStage.vue | 56 +-- .../withdraw-stages/MethodSelectionStage.vue | 32 +- .../withdraw-stages/TaxFormStage.vue | 10 +- .../ui/moderation/BatchScanProgressAlert.vue | 6 +- .../moderation/GlobalDetailLocalTraceCard.vue | 12 +- .../ui/moderation/GlobalDetailTracesList.vue | 42 +- .../ui/moderation/MaliciousSummaryModal.vue | 52 +-- .../ui/moderation/ModerationProjectNags.vue | 39 +- .../ui/moderation/ModerationQueueCard.vue | 32 +- .../ui/moderation/ModpackScanModal.vue | 69 ++- .../checklist/ModpackPermissionsFlow.vue | 62 +-- .../moderation/settings/ModerationKeybind.vue | 18 +- .../src/components/ui/news/LatestNewsRow.vue | 12 +- .../ui/project-settings/CompatibilityCard.vue | 46 +- .../stages/UploadCustomModpack.vue | 40 +- .../servers/marketing/MedalPlanPromotion.vue | 19 +- .../servers/marketing/ServerPlanSelector.vue | 25 +- .../components/ui/thread/ThreadMessage.vue | 31 +- .../src/components/ui/thread/ThreadView.vue | 59 +-- apps/frontend/src/error.vue | 21 +- .../src/pages/[type]/[project]/changelog.vue | 25 +- .../[type]/[project]/settings/environment.vue | 17 +- .../pages/[type]/[project]/settings/index.vue | 131 +++--- .../pages/[type]/[project]/settings/links.vue | 27 +- .../[type]/[project]/settings/permissions.vue | 47 +-- .../[type]/[project]/settings/server.vue | 27 +- .../[type]/[project]/settings/versions.vue | 35 +- apps/frontend/src/pages/admin/affiliates.vue | 12 +- .../src/pages/admin/analytics/events.vue | 91 ++-- .../src/pages/admin/billing/[user].vue | 101 ++--- apps/frontend/src/pages/admin/docs.vue | 41 +- apps/frontend/src/pages/admin/emails.vue | 42 +- .../src/pages/admin/servers/notices.vue | 70 +-- .../src/pages/admin/servers/transfers.vue | 27 +- apps/frontend/src/pages/admin/user_email.vue | 13 +- apps/frontend/src/pages/app.vue | 49 ++- apps/frontend/src/pages/auth/authorize.vue | 29 +- .../src/pages/auth/reset-password.vue | 28 +- apps/frontend/src/pages/auth/verify-email.vue | 87 ++-- apps/frontend/src/pages/dashboard.vue | 12 +- .../src/pages/dashboard/affiliate-links.vue | 12 +- .../src/pages/dashboard/collections.vue | 17 +- apps/frontend/src/pages/dashboard/index.vue | 12 +- .../src/pages/dashboard/notifications.vue | 27 +- .../src/pages/dashboard/organizations.vue | 13 +- .../src/pages/dashboard/revenue/transfers.vue | 21 +- apps/frontend/src/pages/hosting/index.vue | 45 +- apps/frontend/src/pages/index.vue | 36 +- .../pages/moderation/external-projects.vue | 59 ++- .../pages/moderation/global-traces/[key].vue | 28 +- apps/frontend/src/pages/moderation/index.vue | 25 +- .../src/pages/moderation/reports/index.vue | 2 +- .../moderation/technical-review/[project].vue | 12 +- .../moderation/technical-review/index.vue | 2 +- .../src/pages/news/article/[slug].vue | 27 +- apps/frontend/src/pages/news/index.vue | 27 +- .../src/pages/organization/[organization].vue | 22 +- .../[organization]/settings/index.vue | 51 +-- .../[organization]/settings/members.vue | 154 +++---- .../[organization]/settings/projects.vue | 176 ++++---- apps/frontend/src/pages/plus.vue | 45 +- apps/frontend/src/pages/report.vue | 69 +-- apps/frontend/src/pages/settings/flags.vue | 15 +- apps/frontend/src/pages/settings/index.vue | 10 +- apps/frontend/src/pages/settings/pats.vue | 126 +++--- apps/frontend/src/pages/settings/sessions.vue | 10 +- apps/frontend/src/pages/share/[inviteId].vue | 12 +- .../manage-server-compatibility-modal.ts | 2 - .../affiliate/AffiliateLinkCard.vue | 18 +- .../affiliate/AffiliateLinkCreateModal.vue | 21 +- .../ui/src/components/base/Admonition.vue | 18 +- .../ui/src/components/base/BaseTerminal.vue | 17 +- packages/ui/src/components/base/Button.vue | 142 ------- .../ui/src/components/base/ButtonStyled.vue | 397 ------------------ packages/ui/src/components/base/Card.vue | 16 +- packages/ui/src/components/base/Chips.vue | 6 +- .../components/base/CollapsibleAdmonition.vue | 35 +- .../src/components/base/CollapsibleRegion.vue | 14 +- .../src/components/base/DropdownFilterBar.vue | 42 +- .../components/base/ErrorInformationCard.vue | 44 +- .../ui/src/components/base/FloatingPanel.vue | 32 +- .../ui/src/components/base/I18nDebugPanel.vue | 53 ++- .../ui/src/components/base/JoinedButtons.vue | 151 ------- .../ui/src/components/base/ManySelect.vue | 57 ++- .../ui/src/components/base/MarkdownEditor.vue | 130 +++--- .../src/components/base/MultiStageModal.vue | 108 +++-- .../ui/src/components/base/OverflowMenu.vue | 76 ++-- .../ui/src/components/base/Pagination.vue | 79 ++-- .../ui/src/components/base/PopoutMenu.vue | 13 +- .../ui/src/components/base/ServerNotice.vue | 19 +- .../components/base/StackedAdmonitions.vue | 48 +-- .../components/base/TeleportOverflowMenu.vue | 139 +++--- .../src/components/base/TimeFramePicker.vue | 23 +- .../components/base/UnsavedChangesPopup.vue | 24 +- .../ui/src/components/base/buttons/Button.vue | 11 +- .../ui/src/components/base/buttons/types.ts | 9 + packages/ui/src/components/base/index.ts | 6 +- .../billing/AddPaymentMethodModal.vue | 28 +- .../billing/ModrinthServersPurchaseModal.vue | 89 ++-- .../components/billing/ResubscribeModal.vue | 28 +- .../billing/ServersGuestPlanModal.vue | 21 +- .../billing/ServersPurchase0Plan.vue | 89 ++-- .../billing/ServersPurchase3Review.vue | 18 +- packages/ui/src/components/chart/Chart.vue | 10 +- .../AddFilesToAttributionGroupModal.vue | 34 +- .../AddToExistingExternalProjectModal.vue | 69 +-- .../AddToGlobalPermissionsDatabaseModal.vue | 31 +- .../external_files/AttributionEditor.vue | 66 +-- .../ExternalProjectLookupCard.vue | 17 +- .../ExternalProjectPermissionsCard.vue | 231 +++++----- .../components/CustomSetupStage.vue | 22 +- .../components/ImportInstanceStage.vue | 38 +- .../components/ModpackStage.vue | 45 +- .../components/modal/ConfirmLeaveModal.vue | 22 +- .../ui/src/components/modal/ConfirmModal.vue | 27 +- packages/ui/src/components/modal/NewModal.vue | 37 +- .../src/components/modal/OpenInAppModal.vue | 35 +- .../ui/src/components/modal/ShareModal.vue | 151 +++---- .../modal/UnknownFileWarningModal.vue | 20 +- .../src/components/nav/NotificationPanel.vue | 52 +-- .../components/nav/PopupNotificationPanel.vue | 37 +- .../notifications/NotificationToast.vue | 101 ++--- .../project/ProjectPageVersions.vue | 14 +- .../server/ServerModpackContentCard.vue | 15 +- .../environment/ProjectEnvironmentModal.vue | 36 +- .../components/search/SearchSidebarFilter.vue | 62 ++- .../components/servers/InstallingBanner.vue | 24 +- .../ui/src/components/servers/SaveBanner.vue | 46 +- .../src/components/servers/ServerListing.vue | 92 ++-- .../src/components/servers/ServersPromo.vue | 33 +- .../components/servers/access/AccessTable.vue | 90 ++-- .../servers/access/GrantAccessModal.vue | 28 +- .../servers/access/RemoveAccessModal.vue | 34 +- .../servers/admonitions/BackupAdmonition.vue | 46 +- .../admonitions/FileOperationAdmonition.vue | 22 +- .../servers/admonitions/UploadAdmonition.vue | 8 +- .../servers/backups/BackupCreateModal.vue | 32 +- .../servers/backups/BackupDeleteModal.vue | 32 +- .../components/servers/backups/BackupItem.vue | 60 +-- .../servers/backups/BackupRenameModal.vue | 44 +- .../servers/backups/BackupRestoreModal.vue | 34 +- .../marketing/MedalServerCountdown.vue | 15 +- .../servers/marketing/MedalServerListing.vue | 18 +- .../server-header/PanelServerActionButton.vue | 102 +++-- .../server-list-empty/ServerListEmpty.vue | 22 +- .../sharing/invite-players-modal/index.vue | 41 +- ...nvite-players-modal-invite-link-editor.vue | 55 ++- .../invite-players-modal-user-row.vue | 39 +- .../ui/src/components/user/UserPageHeader.vue | 48 +-- .../src/components/version/VersionSummary.vue | 43 +- .../SelectedProjectsFloatingBar.vue | 35 +- .../components/SelectedProjectsLeaveModal.vue | 37 +- .../src/layouts/shared/browse-tab/header.vue | 20 +- .../src/layouts/shared/browse-tab/layout.vue | 77 ++-- .../src/layouts/shared/browse-tab/sidebar.vue | 13 +- .../components/ConsoleActionButtons.vue | 78 ++-- .../ui/src/layouts/shared/console/layout.vue | 22 +- .../components/ContentSelectionBar.vue | 87 ++-- .../modals/ConfirmBulkUpdateModal.vue | 32 +- .../modals/ConfirmDeletionModal.vue | 32 +- .../components/modals/ConfirmDisableModal.vue | 32 +- .../modals/ConfirmModpackUpdateModal.vue | 36 +- .../modals/ConfirmReinstallModal.vue | 22 +- .../components/modals/ConfirmRepairModal.vue | 22 +- .../components/modals/ConfirmUnlinkModal.vue | 32 +- .../modals/ContentDependencyWarningModal.vue | 32 +- .../components/modals/ContentInstallModal.vue | 103 ++--- .../components/modals/InlineBackupCreator.vue | 40 +- .../components/modals/ModpackContentModal.vue | 23 +- .../modals/content-updater-modal/index.vue | 70 ++- .../files-tab/components/FileContextMenu.vue | 31 +- .../files-tab/components/FileManagerError.vue | 14 +- .../files-tab/components/FileNavbar.vue | 149 +++---- .../files-tab/components/FileTableRow.vue | 66 ++- .../components/editor/EditorFindReplace.vue | 98 ++--- .../components/editor/FileImageViewer.vue | 20 +- .../components/modals/FileCreateItemModal.vue | 14 +- .../components/modals/FileDeleteItemModal.vue | 14 +- .../components/modals/FileMoveItemModal.vue | 14 +- .../components/modals/FileRenameItemModal.vue | 14 +- .../modals/FileUnsavedChangesModal.vue | 20 +- .../modals/FileUploadConflictModal.vue | 14 +- .../modals/FileUploadZipUrlModal.vue | 17 +- .../components/upload/FileUploadDropdown.vue | 6 +- .../src/layouts/shared/files-tab/layout.vue | 31 +- .../ui/src/layouts/shared/files-tab/types.ts | 2 +- .../components/ContentDiffModal.vue | 80 ++-- .../components/IncompatibleContentModal.vue | 93 ++-- .../shared/installation-settings/layout.vue | 221 +++++----- .../shared/server-settings/pages/advanced.vue | 110 ++--- .../server-settings/pages/installation.vue | 44 +- .../shared/server-settings/pages/network.vue | 143 ++++--- .../layouts/shared/user-profile/layout.vue | 94 ++--- .../wrapped/AccountProfileSettings.vue | 54 +-- .../layouts/wrapped/AccountSocialSettings.vue | 57 ++- .../hosting/manage/[id]/access/access.vue | 25 +- .../hosting/manage/[id]/onboarding.vue | 35 +- .../wrapped/hosting/manage/backups.vue | 92 ++-- .../layouts/wrapped/hosting/manage/index.vue | 33 +- .../layouts/wrapped/hosting/manage/root.vue | 106 +++-- .../ui/src/stories/base/Admonition.stories.ts | 16 +- .../ui/src/stories/base/Button.stories.ts | 83 ---- .../src/stories/base/ButtonStyled.stories.ts | 226 ---------- .../ui/src/stories/base/EmptyState.stories.ts | 8 +- .../src/stories/base/JoinedButtons.stories.ts | 122 ------ .../src/stories/base/OverflowMenu.stories.ts | 81 ---- .../ui/src/stories/base/PopoutMenu.stories.ts | 94 ----- .../base/StackedAdmonitions.stories.ts | 8 +- .../instances/ContentCardTable.stories.ts | 16 +- .../ContentDependencyWarningModal.stories.ts | 12 +- .../instances/ContentUpdaterModal.stories.ts | 16 +- .../instances/ModpackContentModal.stories.ts | 32 +- .../modal/ConfirmLeaveModal.stories.ts | 12 +- .../ui/src/stories/modal/NewModal.stories.ts | 68 +-- .../src/stories/modal/ShareModal.stories.ts | 8 +- .../src/stories/modal/TabbedModal.stories.ts | 32 +- .../modal/UnknownFileWarningModal.stories.ts | 8 +- .../modal/UpdateToPlayModal.stories.ts | 4 +- .../modal/UploadProgressModal.stories.ts | 8 +- .../stories/nav/NotificationPanel.stories.ts | 44 +- .../nav/PopupNotificationPanel.stories.ts | 44 +- .../servers/CreationFlowModal.stories.ts | 12 +- .../servers/GrantAccessModal.stories.ts | 8 +- .../servers/RemoveAccessModal.stories.ts | 8 +- .../ServerPanelAdmonitionCopyDraft.stories.ts | 19 +- .../sharing/InvitePlayersModal.stories.ts | 4 +- .../ui/src/stories/skin/SkinButton.stories.ts | 6 +- 337 files changed, 6325 insertions(+), 8078 deletions(-) delete mode 100644 packages/ui/src/components/base/Button.vue delete mode 100644 packages/ui/src/components/base/ButtonStyled.vue delete mode 100644 packages/ui/src/components/base/JoinedButtons.vue delete mode 100644 packages/ui/src/stories/base/Button.stories.ts delete mode 100644 packages/ui/src/stories/base/ButtonStyled.stories.ts delete mode 100644 packages/ui/src/stories/base/JoinedButtons.stories.ts delete mode 100644 packages/ui/src/stories/base/OverflowMenu.stories.ts delete mode 100644 packages/ui/src/stories/base/PopoutMenu.stories.ts diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index e2f78fe965..0fe263283b 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -27,9 +27,10 @@ import { UserIcon, } from '@modrinth/assets' import { + IconButton, Admonition, Avatar, - ButtonStyled, + ButtonLink, commonMessages, ContentInstallModal, ContentUpdaterModal, @@ -455,6 +456,18 @@ const messages = defineMessages({ id: 'app.news.view-all', defaultMessage: 'View all news', }, + toggleSidebar: { + id: 'app.sidebar.toggle', + defaultMessage: 'Toggle sidebar', + }, + goBack: { + id: 'app.navigation.go-back', + defaultMessage: 'Go back', + }, + goForward: { + id: 'app.navigation.go-forward', + defaultMessage: 'Go forward', + }, playingAs: { id: 'app.sidebar.playing-as', defaultMessage: 'Playing as', @@ -1642,49 +1655,40 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
- - - - - - +
- - - +
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 9c0611ef63..f9c57f868c 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -4,13 +4,11 @@ class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2" > {{ formatMessage(messages.notSignedIn) }} - - - + - - - + @@ -92,10 +91,11 @@ import { SpinnerIcon, TrashIcon, } from '@modrinth/assets' +import Button from '@modrinth/ui/src/components/base/buttons/Button.vue' import { + IconButton, Accordion, Avatar, - ButtonStyled, defineMessages, injectNotificationManager, useVIntl, diff --git a/apps/app-frontend/src/components/ui/AddContentButton.vue b/apps/app-frontend/src/components/ui/AddContentButton.vue index cdea116e87..855b17e7ae 100644 --- a/apps/app-frontend/src/components/ui/AddContentButton.vue +++ b/apps/app-frontend/src/components/ui/AddContentButton.vue @@ -1,12 +1,28 @@ diff --git a/apps/app-frontend/src/components/ui/AppActionBar.vue b/apps/app-frontend/src/components/ui/AppActionBar.vue index 33f0a207d3..ca2681ce5b 100644 --- a/apps/app-frontend/src/components/ui/AppActionBar.vue +++ b/apps/app-frontend/src/components/ui/AppActionBar.vue @@ -1,15 +1,11 @@ - - - - diff --git a/packages/ui/src/components/base/ButtonStyled.vue b/packages/ui/src/components/base/ButtonStyled.vue deleted file mode 100644 index f546054c16..0000000000 --- a/packages/ui/src/components/base/ButtonStyled.vue +++ /dev/null @@ -1,397 +0,0 @@ - - - - - diff --git a/packages/ui/src/components/base/Card.vue b/packages/ui/src/components/base/Card.vue index f52f4533b0..7dd667806b 100644 --- a/packages/ui/src/components/base/Card.vue +++ b/packages/ui/src/components/base/Card.vue @@ -1,8 +1,8 @@ - - - - diff --git a/packages/ui/src/components/base/ManySelect.vue b/packages/ui/src/components/base/ManySelect.vue index ed495af309..15275b840f 100644 --- a/packages/ui/src/components/base/ManySelect.vue +++ b/packages/ui/src/components/base/ManySelect.vue @@ -1,23 +1,18 @@ - - + - - - + + diff --git a/packages/ui/src/components/base/TimeFramePicker.vue b/packages/ui/src/components/base/TimeFramePicker.vue index 0db65f9e14..b9bd31ada6 100644 --- a/packages/ui/src/components/base/TimeFramePicker.vue +++ b/packages/ui/src/components/base/TimeFramePicker.vue @@ -103,16 +103,12 @@
- - - - - - + +
@@ -193,11 +189,11 @@ + + diff --git a/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue b/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue index a66f9f456f..fc8581c127 100644 --- a/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue +++ b/packages/ui/src/components/base/buttons/TeleportPopoutMenu.vue @@ -7,6 +7,7 @@ import IconButton from './IconButton.vue' import type { ButtonColor, ButtonElementHandle, + ButtonInteraction, ButtonSize, ButtonType, TeleportPlacement, @@ -20,8 +21,10 @@ const props = withDefaults( type?: ButtonType color?: ButtonColor size?: ButtonSize + interaction?: ButtonInteraction disabled?: boolean iconOnly?: boolean + autoFocus?: boolean placement?: TeleportPlacement panelRole?: 'dialog' | 'region' }>(), @@ -30,6 +33,7 @@ const props = withDefaults( size: 'md', disabled: false, iconOnly: false, + autoFocus: true, placement: 'bottom-end', panelRole: 'dialog', }, @@ -64,7 +68,7 @@ async function openMenu() { if (props.disabled || isOpen.value) return await open() emit('open') - await nextTick(focusPanel) + if (props.autoFocus) await nextTick(focusPanel) } function closeMenu(restoreFocus = true) { @@ -105,6 +109,7 @@ defineExpose({ open: openMenu, close: closeMenu }) :type="props.type" :color="props.color" :size="props.size" + :interaction="props.interaction" :disabled="props.disabled" :aria-expanded="isOpen" :aria-controls="panelId" diff --git a/packages/ui/src/components/base/buttons/index.ts b/packages/ui/src/components/base/buttons/index.ts index b1fec25533..cbfc192984 100644 --- a/packages/ui/src/components/base/buttons/index.ts +++ b/packages/ui/src/components/base/buttons/index.ts @@ -9,6 +9,7 @@ export { default as TeleportPopoutMenu } from './TeleportPopoutMenu.vue' export type { ButtonColor, ButtonElementHandle, + ButtonInteraction, ButtonLinkDestination, ButtonNativeType, ButtonSize, diff --git a/packages/ui/src/components/base/buttons/types.ts b/packages/ui/src/components/base/buttons/types.ts index e759ac818c..d37c9ad236 100644 --- a/packages/ui/src/components/base/buttons/types.ts +++ b/packages/ui/src/components/base/buttons/types.ts @@ -7,6 +7,8 @@ export type ButtonType = 'base' | 'colored' | 'outlined' | 'quiet' export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' +export type ButtonInteraction = 'surface' | 'filled' | 'none' + // TODO: Standardized color string enum props across @modrinth/ui export type ButtonColor = | 'brand' @@ -19,6 +21,7 @@ export type ButtonColor = export type ButtonVisualProps = { size?: ButtonSize + interaction?: ButtonInteraction } & ( | { type?: 'base' @@ -44,6 +47,7 @@ export interface ButtonProps { type?: ButtonType color?: ButtonColor size?: ButtonSize + interaction?: ButtonInteraction nativeType?: ButtonNativeType disabled?: boolean loading?: boolean @@ -69,7 +73,9 @@ export interface OverflowMenuItemBase { disabled?: boolean tooltip?: string remainOpen?: boolean - tone?: 'default' | 'red' + tone?: 'default' | ButtonColor + hoverFilled?: boolean + hoverFilledOnly?: boolean } export interface OverflowMenuAction extends OverflowMenuItemBase { diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index aa22dac0be..08b54ef241 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -19,6 +19,7 @@ export { default as TeleportOverflowMenu } from './buttons/TeleportOverflowMenu. export { default as TeleportPopoutMenu } from './buttons/TeleportPopoutMenu.vue' export type { ButtonColor, + ButtonInteraction, ButtonNativeType, ButtonSize, ButtonType, diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index cdb7b60038..ac7afe0eb6 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -558,33 +558,36 @@ function goToBreadcrumbStep(id: string) {
- + -