mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 17:14:50 +00:00
* feat: refactor button components
* fix: qa
* fix: storybook
* a11y: pass
* fix: build
* fix: rename default ->md
* fix: lint
* docs: buttons.md
* refactor part 1
* refactor part 2
* fix: undo refactor for fresh restart
* refactor
* Revert "refactor"
This reverts commit 96d65902d7.
* refactor: part 1
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: qa
* fix: remove text-contrast
* fix: qa
* fix: v-tooltip
* fix: lint
* fix: split broken
* fix: passkey qa
* fix: qa
* fix: qa
* fix: prepr
* fix: splitbutton
* improve button group seam
---------
Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
376 lines
13 KiB
Markdown
376 lines
13 KiB
Markdown
# Buttons
|
|
|
|
Use the button components in `packages/ui/src/components/base/buttons/` for actions,
|
|
navigation, file selection, and button-owned menus. They share the same sizing,
|
|
interaction states, focus treatment, and visual variants.
|
|
|
|
Do not recreate button styling with raw `<button>` or `<a>` elements. A raw element is
|
|
appropriate only when an interaction has semantics or behaviour that the shared
|
|
components cannot represent.
|
|
|
|
`ButtonFrame` is an internal styling primitive. Use one of the public components instead
|
|
of importing it directly.
|
|
|
|
## Choosing a component
|
|
|
|
| Component | Use |
|
|
| -------------------------- | ----------------------------------------------------------------------------------- |
|
|
| `Button` | An action, including form submission |
|
|
| `ButtonLink` | Navigation to an internal route or external URL |
|
|
| `IconButton` | An icon-only action |
|
|
| `FileButton` | Opening a file picker, with optional drag-and-drop support |
|
|
| `ButtonGroup` | Visually joining related buttons |
|
|
| `SplitButton` | A primary action with an attached overflow menu |
|
|
| `TeleportOverflowMenu` | An icon button that opens a menu of actions and links |
|
|
| `TeleportPopoutMenu` | A button or icon button that opens an arbitrary panel |
|
|
|
|
Use the component that matches the interaction's semantics. Do not use a click handler
|
|
on `ButtonLink` for an action, and do not perform navigation from a `Button`.
|
|
|
|
Import public button components from `@modrinth/ui`. Inside `packages/ui`, use the
|
|
corresponding `#ui/components/base/buttons/*` path:
|
|
|
|
```ts
|
|
import { Button, ButtonLink, IconButton } from '@modrinth/ui'
|
|
```
|
|
|
|
## Basic usage
|
|
|
|
Use `Button` for actions. Its native type defaults to `button`, so set
|
|
`native-type="submit"` explicitly when it submits a form.
|
|
|
|
```vue
|
|
<Button type="colored" @click="save">
|
|
<SaveIcon aria-hidden="true" />
|
|
Save changes
|
|
</Button>
|
|
|
|
<Button native-type="submit" :loading="saveMutation.isPending.value">
|
|
Save
|
|
</Button>
|
|
```
|
|
|
|
Use `ButtonLink` for navigation. Pass exactly one of `to` or `href`:
|
|
|
|
```vue
|
|
<ButtonLink to="/settings">Settings</ButtonLink>
|
|
|
|
<ButtonLink href="https://example.com" target="_blank" type="outlined">
|
|
Documentation
|
|
<ExternalIcon aria-hidden="true" />
|
|
</ButtonLink>
|
|
```
|
|
|
|
`to` creates a Vue Router link. `href` creates a standard anchor. External links opened
|
|
in a new tab receive `rel="noopener noreferrer"` unless a different `rel` is provided.
|
|
|
|
Use `IconButton` when the visible icon is the entire button content. The `label` prop is
|
|
required and supplies the button's accessible name:
|
|
|
|
```vue
|
|
<IconButton label="Open settings" type="quiet" @click="openSettings">
|
|
<SettingsIcon aria-hidden="true" />
|
|
</IconButton>
|
|
```
|
|
|
|
Do not replace `label` with a tooltip or rely on the icon to communicate the action.
|
|
User-visible labels, including `IconButton` labels and menu option labels, must use the
|
|
localization system.
|
|
|
|
## Visual types
|
|
|
|
All button components use the same four visual types:
|
|
|
|
| Type | Use |
|
|
| ---------- | ------------------------------------------------------------------- |
|
|
| `base` | Default neutral actions |
|
|
| `colored` | The highest-emphasis action in a section |
|
|
| `outlined` | Secondary actions that need a visible boundary |
|
|
| `quiet` | Low-emphasis actions in toolbars, cards, and other compact contexts |
|
|
|
|
`base` is the default. Use `colored` deliberately: a group should normally have only one
|
|
highest-emphasis action.
|
|
|
|
The available colors are `brand`, `red`, `orange`, `green`, `blue`, `purple`, and
|
|
`medal_promotion`. Color is supported by `colored`, `outlined`, and `quiet`; `base` uses
|
|
its standard neutral treatment. An outlined button without a color uses the standard
|
|
neutral treatment.
|
|
|
|
```vue
|
|
<Button type="colored" color="brand">Create project</Button>
|
|
<Button type="colored" color="red">Delete project</Button>
|
|
<Button type="outlined">Cancel</Button>
|
|
<Button type="outlined" color="blue">View analytics</Button>
|
|
<Button type="quiet" color="red">Remove</Button>
|
|
```
|
|
|
|
Quiet buttons support an `interaction` prop when their hover and keyboard-focus
|
|
treatment needs to differ from the default surface fill:
|
|
|
|
| Interaction | Treatment |
|
|
| ----------- | --------- |
|
|
| `surface` | Uses the standard neutral hover/focus surface. This is the default. |
|
|
| `filled` | Fills with the button's `color` and uses contrast text. |
|
|
| `none` | Keeps the background transparent while retaining the focus ring. |
|
|
|
|
```vue
|
|
<Button type="quiet" color="brand" interaction="filled">Current page</Button>
|
|
<IconButton type="quiet" interaction="none" label="Open account menu">
|
|
<AvatarIcon aria-hidden="true" />
|
|
</IconButton>
|
|
```
|
|
|
|
Use `interaction` to describe behavior rather than passing arbitrary hover colors.
|
|
Resting selected-state backgrounds, such as a current pagination page, remain the
|
|
responsibility of the owning component.
|
|
|
|
Use red only for destructive or dangerous actions. Do not use color as the only way to
|
|
communicate an action's meaning.
|
|
|
|
## Sizes
|
|
|
|
- `sm`: Notifications and popups, usually for icon-only actions.
|
|
- `md`: Modal and card actions that do not appear at the top of a page.
|
|
- `lg`: Actions at the top of a page or subpage, such as beside inputs or filters.
|
|
- `xl`: Header actions.
|
|
|
|
`md` is the default. Buttons that appear together must use the same size.
|
|
|
|
Prefer the size prop over overriding height, padding, border radius, font weight, gap, or
|
|
icon dimensions with utility classes. Width and layout utilities such as `w-full` are
|
|
fine when required by the surrounding layout.
|
|
|
|
```vue
|
|
<Button size="sm">Compact action</Button>
|
|
<Button>Default action</Button>
|
|
<Button size="lg">Prominent action</Button>
|
|
<Button size="xl">Large action</Button>
|
|
```
|
|
|
|
## States
|
|
|
|
Use `disabled` when an action is unavailable. Use `loading` while a `Button` or
|
|
`IconButton` action is in progress:
|
|
|
|
```vue
|
|
<Button
|
|
type="colored"
|
|
:loading="createMutation.isPending.value"
|
|
@click="createMutation.mutate()"
|
|
>
|
|
Create
|
|
</Button>
|
|
```
|
|
|
|
`loading` disables the underlying button and sets `aria-busy`. Keep the label stable so
|
|
the action remains understandable while it is running.
|
|
|
|
Disabled links are made non-navigable and removed from the tab order by `ButtonLink`.
|
|
Do not reimplement disabled-link click handling in consumers.
|
|
|
|
## Icons and content
|
|
|
|
Place icons directly inside the component so the shared styles can size and space them:
|
|
|
|
```vue
|
|
<Button>
|
|
<DownloadIcon aria-hidden="true" />
|
|
Download
|
|
</Button>
|
|
|
|
<Button>
|
|
Continue
|
|
<RightArrowIcon aria-hidden="true" />
|
|
</Button>
|
|
```
|
|
|
|
Icons next to a visible label are decorative and should use `aria-hidden="true"`. Use
|
|
`IconButton` instead of an empty text button for icon-only actions.
|
|
|
|
Button labels should describe the action directly. Avoid vague labels such as "Yes",
|
|
"OK", or "Submit" when a specific label such as "Delete version" or "Save settings"
|
|
would be clearer.
|
|
|
|
## File buttons
|
|
|
|
`FileButton` owns the file input and emits validated files:
|
|
|
|
```vue
|
|
<FileButton
|
|
prompt="Select images"
|
|
accept="image/*"
|
|
:max-size="MAX_IMAGE_SIZE"
|
|
multiple
|
|
@change="handleFiles"
|
|
>
|
|
<UploadIcon aria-hidden="true" />
|
|
</FileButton>
|
|
```
|
|
|
|
The `change` event receives a `File[]`. `allow-drop` defaults to `true`; set it to
|
|
`false` when the surrounding interface should not accept dropped files.
|
|
|
|
Use `FileButton` for a compact file-selection action. Use a dedicated drop area when
|
|
drag-and-drop is the primary interaction or the interface needs previews, progress, or
|
|
more detailed instructions.
|
|
|
|
## Button groups and split buttons
|
|
|
|
Use `ButtonGroup` only for closely related controls. Pass a localized `label` when the
|
|
relationship is not already clear from surrounding accessible content:
|
|
|
|
```vue
|
|
<ButtonGroup label="Pagination">
|
|
<Button type="outlined">Previous</Button>
|
|
<Button type="outlined">Next</Button>
|
|
</ButtonGroup>
|
|
```
|
|
|
|
`ButtonGroup` joins the buttons visually, so its direct children must be components from
|
|
this button system.
|
|
|
|
Use `SplitButton` when one action is primary and closely related alternatives belong in
|
|
an attached menu:
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { PlayIcon, SettingsIcon, TrashIcon } from '@modrinth/assets'
|
|
import type { OverflowMenuOption } from '@modrinth/ui/src/components/base/buttons/types'
|
|
|
|
const options: OverflowMenuOption[] = [
|
|
{
|
|
id: 'settings',
|
|
label: 'Server settings',
|
|
icon: SettingsIcon,
|
|
action: openSettings,
|
|
},
|
|
{ type: 'divider' },
|
|
{
|
|
id: 'delete',
|
|
label: 'Delete server',
|
|
icon: TrashIcon,
|
|
tone: 'red',
|
|
action: deleteServer,
|
|
},
|
|
]
|
|
</script>
|
|
|
|
<template>
|
|
<SplitButton
|
|
menu-label="More server actions"
|
|
group-label="Server actions"
|
|
type="colored"
|
|
:options="options"
|
|
@click="startServer"
|
|
>
|
|
<PlayIcon aria-hidden="true" />
|
|
Start server
|
|
</SplitButton>
|
|
</template>
|
|
```
|
|
|
|
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`.
|
|
|
|
The trigger is an `IconButton` by default. Set `icon-only="false"` when the trigger has
|
|
visible text or composite content, such as an avatar and chevron; it will use a normal
|
|
`Button` so intrinsic width, padding, and gaps are preserved.
|
|
|
|
- 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` for semantically colored options. The available tones are `brand`, `red`,
|
|
`orange`, `green`, `blue`, `purple`, and `medal_promotion`.
|
|
- Use `hoverFilled: true` when a toned option should fill with its tone on hover and
|
|
focus. Use `hoverFilledOnly: true` to keep the default text color until that state.
|
|
- 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.
|
|
|
|
Set `hoverable` when a navigation menu should also open on pointer hover. Click, touch,
|
|
and keyboard activation remain available.
|
|
|
|
## 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
|
|
<TeleportPopoutMenu label="Configure versions" placement="bottom-start">
|
|
<template #trigger>
|
|
<SettingsIcon aria-hidden="true" />
|
|
Configure
|
|
</template>
|
|
|
|
<template #panel="{ close }">
|
|
<div class="flex w-72 flex-col gap-4">
|
|
<StyledInput v-model="versionName" label="Version name" />
|
|
<Button type="colored" @click="close()">Apply</Button>
|
|
</div>
|
|
</template>
|
|
</TeleportPopoutMenu>
|
|
```
|
|
|
|
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.
|