@dumiflex/wp-kit 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/LICENSE +674 -0
- package/README.md +76 -3
- package/package.json +77 -6
- package/src/Avatar.vue +83 -0
- package/src/Badge.vue +97 -0
- package/src/Banner.vue +118 -0
- package/src/BookmarkButton.vue +27 -0
- package/src/BundleMeta.vue +51 -0
- package/src/Button.vue +103 -0
- package/src/Card.vue +68 -0
- package/src/Checkbox.vue +107 -0
- package/src/Chip.vue +97 -0
- package/src/ChipToggle.vue +92 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +206 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +334 -0
- package/src/ConfirmDialog.vue +163 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +379 -0
- package/src/CopyButton.vue +156 -0
- package/src/DataTable.vue +292 -0
- package/src/DatePicker.vue +234 -0
- package/src/DescriptionList.vue +73 -0
- package/src/DiffView.vue +68 -0
- package/src/Disclosure.vue +166 -0
- package/src/DragHandle.vue +49 -0
- package/src/Drawer.vue +131 -0
- package/src/DropZone.vue +196 -0
- package/src/DropdownMenu.vue +72 -0
- package/src/EmptyState.vue +111 -0
- package/src/Field.vue +28 -0
- package/src/HsvPicker.vue +208 -0
- package/src/Icon.vue +39 -0
- package/src/IconPicker.vue +209 -0
- package/src/ImageGrid.vue +125 -0
- package/src/InlineEdit.vue +183 -0
- package/src/Input.vue +99 -0
- package/src/Kbd.vue +56 -0
- package/src/KindChip.vue +53 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +161 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +76 -0
- package/src/ModalShell.vue +107 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +171 -0
- package/src/PostCard.vue +211 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RatingBadge.vue +42 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +199 -0
- package/src/Select.vue +641 -0
- package/src/Separator.vue +54 -0
- package/src/Skeleton.vue +82 -0
- package/src/Slider.vue +198 -0
- package/src/SortableList.vue +260 -0
- package/src/SplitPane.vue +68 -0
- package/src/StarButton.vue +40 -0
- package/src/Stepper.vue +141 -0
- package/src/SupportLinks.vue +93 -0
- package/src/TagInput.vue +300 -0
- package/src/Textarea.vue +57 -0
- package/src/Timeline.vue +92 -0
- package/src/ToastCard.vue +119 -0
- package/src/ToastHost.vue +40 -0
- package/src/Toggle.vue +36 -0
- package/src/TokenizedValue.vue +310 -0
- package/src/Toolbar.vue +110 -0
- package/src/VirtualList.vue +87 -0
- package/src/VisuallyHidden.vue +29 -0
- package/src/WpButton.vue +75 -0
- package/src/WpCheck.vue +102 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +183 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +89 -0
- package/src/WpSwitch.vue +163 -0
- package/src/WpTabs.vue +188 -0
- package/src/_modal-motion.css +33 -0
- package/src/_skeleton.css +50 -0
- package/src/axis-color.ts +42 -0
- package/src/button.css +169 -0
- package/src/calendar.ts +59 -0
- package/src/clipboard.ts +50 -0
- package/src/colour-presets.ts +12 -0
- package/src/diff.ts +51 -0
- package/src/drop-zone.ts +41 -0
- package/src/flip.ts +237 -0
- package/src/floating.ts +99 -0
- package/src/hsv.ts +100 -0
- package/src/icon-catalog.ts +52 -0
- package/src/image-zoom.ts +93 -0
- package/src/logo-paths.ts +21 -0
- package/src/matrix-axis.ts +91 -0
- package/src/pagination.ts +27 -0
- package/src/platform-keys.ts +29 -0
- package/src/rich-tokenize.ts +357 -0
- package/src/select-types.ts +32 -0
- package/src/slider-math.ts +45 -0
- package/src/sortable.ts +22 -0
- package/src/theme/fonts/OFL-Fraunces.txt +93 -0
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/theme/fonts/OFL-Manrope.txt +93 -0
- package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
- package/src/theme/looks.css +428 -0
- package/src/theme/palettes.css +254 -0
- package/src/theme/tokens.css +2894 -0
- package/src/tokens.ts +56 -0
- package/src/tooltip.css +43 -0
- package/src/tooltip.ts +146 -0
- package/src/useDialogFocus.ts +73 -0
- package/src/useGrowableField.ts +357 -0
- package/src/useModalKeyShield.ts +116 -0
- package/src/useToast.ts +48 -0
- package/src/wp-logo.svg +14 -0
package/README.md
CHANGED
|
@@ -1,3 +1,76 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# wp-kit
|
|
2
|
+
|
|
3
|
+
The Vue 3 component kit shared by the [Wildcard Pipeline](https://github.com/DumiFlex/ComfyUI-Wildcard-Pipeline) extension (its manager and canvas widgets) and the [Wildcard Pipeline Community](https://github.com/DumiFlex/Wildcard-Pipeline-Community) site.
|
|
4
|
+
|
|
5
|
+
Buttons, inputs, selects, checkboxes and toggles, cards, chips, tables, modals, drawers and confirm dialogs, overlays (Popover, DropdownMenu, ContextMenu, tooltip), tabs, sliders, tag, date, colour and icon pickers, lists, image grid and lightbox, toasts, diff and code views, and the logo. And the theme they wear: the design tokens, six colour palettes and seven looks in `src/theme/`.
|
|
6
|
+
|
|
7
|
+
Every piece uses CSS custom properties, so it takes on the colours, corners and type of whichever app it sits in.
|
|
8
|
+
|
|
9
|
+
**See every piece live:** [dumiflex.github.io/wp-kit](https://dumiflex.github.io/wp-kit/), with a switcher for the palette, the look and light or dark.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
The kit is on npm as [`@dumiflex/wp-kit`](https://www.npmjs.com/package/@dumiflex/wp-kit). It ships its source (`.vue`, `.ts` and the theme CSS) with no build step, so your Vite app compiles the pieces it imports with its own Vue plugin:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @dumiflex/wp-kit vue primeicons
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import Button from "@dumiflex/wp-kit/Button.vue";
|
|
21
|
+
import { SHARED_TOKENS } from "@dumiflex/wp-kit/tokens.ts";
|
|
22
|
+
import "@dumiflex/wp-kit/theme/tokens.css";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Import one file at a time (there is no barrel file, on purpose), so the bundler ships only what you use. In `vite.config`, set `resolve.dedupe: ["vue"]` (one Vue, not the app's and a second one) and `optimizeDeps.exclude: ["@dumiflex/wp-kit"]` (the `.vue` sources go through your Vue plugin, not the pre-bundler). An app that already imports the kit as `@/kit/...` can keep that by aliasing `@/kit` to `node_modules/@dumiflex/wp-kit/src`.
|
|
26
|
+
|
|
27
|
+
Each release is staged on npm by the release workflow (trusted publishing, with provenance) and goes live once a maintainer approves it.
|
|
28
|
+
|
|
29
|
+
### Apps that copy the kit
|
|
30
|
+
|
|
31
|
+
The Wildcard Pipeline apps still copy `src/` in at a release tag until they move to the package:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
node scripts/kit-pull.mjs v0.2.1 # in the app repo
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The app's script downloads that tag, copies `src/` into the app's kit folder, and writes the tag and commit next to it. A CI check (`kit-pull.mjs --check`) fails when the copy no longer matches what was pulled, so a fix to a kit piece is made here, released, and pulled into the apps in an ordinary PR.
|
|
38
|
+
|
|
39
|
+
## What a host must provide
|
|
40
|
+
|
|
41
|
+
- **Vue 3.5** and **PrimeIcons 7** (peer dependencies; icons are `pi pi-*` classes). The kit imports nothing else at runtime.
|
|
42
|
+
- **A theme.** A page app loads the kit's own: `theme/tokens.css`, then `theme/palettes.css` and `theme/looks.css`, and `button.css`. It sets `data-wp-palette`, `data-wp-look` and `wp-theme-light` on `<html>` to switch them (see [docs/tokens.md](docs/tokens.md)). A host with its own colour sheet (the ComfyUI canvas) defines the names in `SHARED_TOKENS` ([`src/tokens.ts`](src/tokens.ts)) instead, and uses only the pieces that read nothing else.
|
|
43
|
+
- **`button.css`** loaded once globally if the app uses the `.wp-btn` classes on plain elements. `WpButton` pulls it in on its own.
|
|
44
|
+
|
|
45
|
+
## Rules for a piece
|
|
46
|
+
|
|
47
|
+
- Reads only the shared token names, or, for a piece listed in `PAGE_PIECES`, names `src/theme/` defines (`src/tokens.test.ts` fails otherwise).
|
|
48
|
+
- Every class starts with `wp-`.
|
|
49
|
+
- Fixed corners are `min(Npx, var(--wp-radius-cap, 999px))`, pills `var(--wp-radius-cap, 999px)`, so a host's look can square them off.
|
|
50
|
+
- Props use the shared names `size` / `variant` / `tone` / `v-model:open`; heights come from `--wp-control-h-sm|md|lg` (24 / 32 / 40 px).
|
|
51
|
+
- Overlays place themselves through `floating.ts` and handle Escape, Tab and focus return through `useDialogFocus`.
|
|
52
|
+
- No app code: no stores, routers, ComfyUI globals or `@/` imports. A piece only imports `vue` and other kit files.
|
|
53
|
+
|
|
54
|
+
Every piece has an entry and a demo on the site (`site/src/registry.ts` + `site/src/demos/<id>.vue`); `site/src/coverage.test.ts` fails when one is missing. The longer component standard lives in the extension repo: `docs/component-standard.md`.
|
|
55
|
+
|
|
56
|
+
## Develop
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pnpm install
|
|
60
|
+
pnpm lint
|
|
61
|
+
pnpm typecheck
|
|
62
|
+
pnpm test
|
|
63
|
+
pnpm site:dev # the showcase site on localhost
|
|
64
|
+
pnpm site:build # site/dist, what Pages serves
|
|
65
|
+
pnpm check-package # what npm would publish: the files apps import, nothing repo-only
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Node 22+ and pnpm 9. The site lives in `site/` (outside `src/`, so the apps never copy it). It is deployed to GitHub Pages by `.github/workflows/site.yml` on every release and on "Run workflow"; CI builds it on every PR.
|
|
69
|
+
|
|
70
|
+
## Versions and releases
|
|
71
|
+
|
|
72
|
+
Commits and PR titles follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `refactor:` …); the `pr-title` check enforces it. Work goes into `dev` by squash-merged PRs. Merging `dev` into `main` ships: release-please opens a release PR with the next version and `CHANGELOG.md`, it merges itself once CI passes, and the `vX.Y.Z` tag it makes is what the apps pull. While the kit is below 1.0, a `feat` bumps the minor version and a `fix` the patch.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
[GPL-3.0-or-later](LICENSE). Copyright (C) 2026 DumiFlex.
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@dumiflex/wp-kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@dumiflex/wp-kit",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Vue 3 component kit shared by the Wildcard Pipeline manager, canvas and community site.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vue",
|
|
7
|
+
"vue3",
|
|
8
|
+
"components",
|
|
9
|
+
"design-tokens",
|
|
10
|
+
"comfyui",
|
|
11
|
+
"wildcard-pipeline"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://dumiflex.github.io/wp-kit/",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/DumiFlex/wp-kit/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "GPL-3.0-or-later",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/__tests__",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
"./*": "./src/*"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": [
|
|
30
|
+
"**/*.css",
|
|
31
|
+
"**/*.vue"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/DumiFlex/wp-kit.git"
|
|
39
|
+
},
|
|
40
|
+
"packageManager": "pnpm@9.12.0",
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"typecheck": "vue-tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"site:dev": "vite --config site/vite.config.ts",
|
|
50
|
+
"site:build": "vite build --config site/vite.config.ts",
|
|
51
|
+
"site:preview": "vite preview --config site/vite.config.ts",
|
|
52
|
+
"check-package": "node scripts/check-package.mjs"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"vue": "^3.5.0",
|
|
56
|
+
"primeicons": "^7.0.0 || ^8.0.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^22.10.0",
|
|
60
|
+
"@typescript-eslint/eslint-plugin": "8.59.0",
|
|
61
|
+
"@typescript-eslint/parser": "8.59.0",
|
|
62
|
+
"@vitejs/plugin-vue": "5.2.4",
|
|
63
|
+
"@vue/test-utils": "2.4.8",
|
|
64
|
+
"@vue/tsconfig": "0.5.1",
|
|
65
|
+
"eslint": "9.39.4",
|
|
66
|
+
"eslint-plugin-vue": "9.33.0",
|
|
67
|
+
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
|
68
|
+
"jsdom": "29.0.2",
|
|
69
|
+
"primeicons": "7.0.0",
|
|
70
|
+
"typescript": "5.9.3",
|
|
71
|
+
"vite": "5.4.21",
|
|
72
|
+
"vitest": "2.1.9",
|
|
73
|
+
"vue": "3.5.44",
|
|
74
|
+
"vue-eslint-parser": "10.4.0",
|
|
75
|
+
"vue-tsc": "2.2.12"
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/Avatar.vue
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Avatar: a person's picture, or their initials on a colour picked from
|
|
4
|
+
* their name when there is no picture (or it fails to load). For community
|
|
5
|
+
* authors, chat participants and history entries. Works on the canvas and
|
|
6
|
+
* in the manager.
|
|
7
|
+
*
|
|
8
|
+
* <Avatar name="Dumi Flex" :src="author.avatar_url" />
|
|
9
|
+
* <Avatar name="Pack bot" icon="pi-box" size="sm" />
|
|
10
|
+
*
|
|
11
|
+
* The name is the image's alt text; with `decorative` (the name is already
|
|
12
|
+
* written beside it) screen readers skip it.
|
|
13
|
+
*/
|
|
14
|
+
import { computed, ref, watch } from "vue";
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<{
|
|
17
|
+
name: string;
|
|
18
|
+
src?: string | null | undefined;
|
|
19
|
+
/** A PrimeIcons class to draw instead of initials. */
|
|
20
|
+
icon?: string | undefined;
|
|
21
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
22
|
+
shape?: "circle" | "square" | undefined;
|
|
23
|
+
/** The name is written next to it already. */
|
|
24
|
+
decorative?: boolean | undefined;
|
|
25
|
+
}>(), { src: null, icon: undefined, size: "md", shape: "circle" });
|
|
26
|
+
|
|
27
|
+
const failed = ref(false);
|
|
28
|
+
watch(() => props.src, () => { failed.value = false; });
|
|
29
|
+
|
|
30
|
+
const initials = computed(() => {
|
|
31
|
+
const words = props.name.trim().split(/\s+/).filter(Boolean);
|
|
32
|
+
if (!words.length) return "?";
|
|
33
|
+
const first = [...words[0]][0] ?? "";
|
|
34
|
+
const last = words.length > 1 ? [...words[words.length - 1]][0] ?? "" : "";
|
|
35
|
+
return (first + last).toUpperCase();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const HUES = [
|
|
39
|
+
"var(--wp-var-1)", "var(--wp-var-2)", "var(--wp-var-3)", "var(--wp-var-4)",
|
|
40
|
+
"var(--wp-var-5)", "var(--wp-var-6)", "var(--wp-var-7)", "var(--wp-var-8)",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** One of the eight variable colours, the same for the same name every time. */
|
|
44
|
+
const hue = computed(() => {
|
|
45
|
+
let h = 0;
|
|
46
|
+
for (const ch of props.name) h = (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0;
|
|
47
|
+
return HUES[h % 8];
|
|
48
|
+
});
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<span
|
|
53
|
+
class="wp-avatar"
|
|
54
|
+
:data-size="size"
|
|
55
|
+
:data-shape="shape"
|
|
56
|
+
:style="{ '--_hue': hue }"
|
|
57
|
+
:role="decorative ? undefined : 'img'"
|
|
58
|
+
:aria-label="decorative ? undefined : name"
|
|
59
|
+
:aria-hidden="decorative ? 'true' : undefined"
|
|
60
|
+
>
|
|
61
|
+
<img v-if="src && !failed" class="wp-avatar__img" :src="src" alt="" @error="failed = true">
|
|
62
|
+
<i v-else-if="icon" class="pi wp-avatar__icon" :class="icon" aria-hidden="true" />
|
|
63
|
+
<span v-else class="wp-avatar__initials" aria-hidden="true">{{ initials }}</span>
|
|
64
|
+
</span>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
.wp-avatar {
|
|
69
|
+
--_s: var(--wp-control-h-md);
|
|
70
|
+
position: relative; display: inline-grid; place-items: center; flex: none; overflow: hidden;
|
|
71
|
+
width: var(--_s); height: var(--_s); border-radius: 50%; /* round: avatar */
|
|
72
|
+
background: color-mix(in srgb, var(--_hue) 22%, var(--wp-bg-2));
|
|
73
|
+
color: color-mix(in srgb, var(--_hue) 70%, var(--wp-text));
|
|
74
|
+
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_hue) 30%, transparent);
|
|
75
|
+
font: 600 calc(var(--_s) * 0.4)/1 var(--wp-font-sans); letter-spacing: 0.02em; user-select: none;
|
|
76
|
+
}
|
|
77
|
+
.wp-avatar[data-size="sm"] { --_s: var(--wp-control-h-sm); }
|
|
78
|
+
.wp-avatar[data-size="lg"] { --_s: var(--wp-control-h-lg); }
|
|
79
|
+
.wp-avatar[data-shape="square"] { border-radius: var(--wp-radius); }
|
|
80
|
+
.wp-avatar[data-size="sm"][data-shape="square"] { border-radius: var(--wp-radius-sm); }
|
|
81
|
+
.wp-avatar__img { width: 100%; height: 100%; object-fit: cover; }
|
|
82
|
+
.wp-avatar__icon { font-size: calc(var(--_s) * 0.45); }
|
|
83
|
+
</style>
|
package/src/Badge.vue
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Badge: a count bubble or a status dot. On its own it sits in the line of
|
|
4
|
+
* text; wrapped around something (a button, an icon, an avatar) it sits on
|
|
5
|
+
* that thing's top-right corner. Works on the canvas and in the manager.
|
|
6
|
+
*
|
|
7
|
+
* <Badge :count="3" tone="danger" label="3 conflicts"><Button icon="pi-bell" aria-label="Problems" /></Badge>
|
|
8
|
+
* <Badge dot tone="success" text="Running" />
|
|
9
|
+
* <Badge dot tone="warn" label="Out of date" />
|
|
10
|
+
*
|
|
11
|
+
* Colour is never the only signal: a dot without `text` needs a `label`,
|
|
12
|
+
* which screen readers read in its place. Counts over `max` read
|
|
13
|
+
* "99+". A count of 0 hides the badge unless `show-zero`.
|
|
14
|
+
*/
|
|
15
|
+
import { computed, useSlots } from "vue";
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(defineProps<{
|
|
18
|
+
count?: number | undefined;
|
|
19
|
+
/** Counts above this read "max+". */
|
|
20
|
+
max?: number | undefined;
|
|
21
|
+
/** A dot instead of a number. */
|
|
22
|
+
dot?: boolean | undefined;
|
|
23
|
+
tone?: "neutral" | "info" | "success" | "warn" | "danger" | "accent" | undefined;
|
|
24
|
+
/** Visible text after the dot or count (a status line). */
|
|
25
|
+
text?: string | undefined;
|
|
26
|
+
/** Name for screen readers when the badge has no text, e.g. "3 conflicts". */
|
|
27
|
+
label?: string | undefined;
|
|
28
|
+
/** Show a count of 0 instead of hiding the badge. */
|
|
29
|
+
showZero?: boolean | undefined;
|
|
30
|
+
/** Draw the dot as a soft pulse (something is running). */
|
|
31
|
+
pulse?: boolean | undefined;
|
|
32
|
+
}>(), { count: undefined, max: 99, tone: "accent", text: undefined, label: undefined });
|
|
33
|
+
|
|
34
|
+
const slots = useSlots();
|
|
35
|
+
const anchored = computed(() => !!slots.default);
|
|
36
|
+
const shown = computed(() => props.dot || (props.count !== undefined && (props.count > 0 || props.showZero)));
|
|
37
|
+
const countText = computed(() =>
|
|
38
|
+
props.count === undefined ? "" : props.count > props.max ? `${props.max}+` : String(props.count),
|
|
39
|
+
);
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<span v-if="anchored" class="wp-badge-anchor">
|
|
44
|
+
<slot />
|
|
45
|
+
<span v-if="shown" class="wp-badge wp-badge--corner" :data-tone="tone" :data-dot="dot || undefined" :data-pulse="pulse || undefined" aria-hidden="true"><template v-if="!dot">{{ countText }}</template></span>
|
|
46
|
+
<span v-if="shown && (label || countText)" class="wp-badge__sr">{{ label ?? countText }}</span>
|
|
47
|
+
</span>
|
|
48
|
+
<span v-else-if="shown || text" class="wp-badge-line" :data-tone="tone">
|
|
49
|
+
<span v-if="shown" class="wp-badge" :data-tone="tone" :data-dot="dot || undefined" :data-pulse="pulse || undefined" aria-hidden="true"><template v-if="!dot">{{ countText }}</template></span>
|
|
50
|
+
<span v-if="!text && (label || countText)" class="wp-badge__sr">{{ label ?? countText }}</span>
|
|
51
|
+
<span v-if="text" class="wp-badge-line__text">{{ text }}</span>
|
|
52
|
+
</span>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.wp-badge-anchor { position: relative; display: inline-flex; }
|
|
57
|
+
.wp-badge__sr {
|
|
58
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
59
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
60
|
+
}
|
|
61
|
+
.wp-badge-line {
|
|
62
|
+
display: inline-flex; align-items: center; gap: var(--wp-space-2); min-width: 0;
|
|
63
|
+
color: var(--wp-text-muted); font: 500 var(--wp-text-xs)/1.2 var(--wp-font-sans);
|
|
64
|
+
}
|
|
65
|
+
.wp-badge-line__text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
66
|
+
.wp-badge {
|
|
67
|
+
--_tone: var(--wp-accent);
|
|
68
|
+
display: inline-grid; place-items: center; flex: none; box-sizing: border-box;
|
|
69
|
+
min-width: 18px; height: 18px; padding: 0 5px;
|
|
70
|
+
border-radius: var(--wp-radius-cap, 999px); background: var(--_tone); color: var(--wp-bg);
|
|
71
|
+
font: 600 10.5px/1 var(--wp-font-sans); font-variant-numeric: tabular-nums;
|
|
72
|
+
}
|
|
73
|
+
.wp-badge[data-tone="neutral"] { --_tone: var(--wp-text-dim); }
|
|
74
|
+
.wp-badge[data-tone="info"] { --_tone: var(--wp-info); }
|
|
75
|
+
.wp-badge[data-tone="success"] { --_tone: var(--wp-success); }
|
|
76
|
+
.wp-badge[data-tone="warn"] { --_tone: var(--wp-warn); }
|
|
77
|
+
.wp-badge[data-tone="danger"] { --_tone: var(--wp-danger); }
|
|
78
|
+
.wp-badge[data-dot] { min-width: 0; width: 8px; height: 8px; padding: 0; }
|
|
79
|
+
.wp-badge--corner {
|
|
80
|
+
position: absolute; top: 0; right: 0; transform: translate(40%, -40%);
|
|
81
|
+
box-shadow: 0 0 0 2px var(--wp-bg-1); pointer-events: none;
|
|
82
|
+
}
|
|
83
|
+
.wp-badge--corner[data-dot] { transform: translate(25%, -25%); }
|
|
84
|
+
.wp-badge[data-pulse]::after {
|
|
85
|
+
content: ""; position: absolute; inset: 0; border-radius: inherit; background: var(--_tone);
|
|
86
|
+
animation: wp-badge-pulse 1.6s ease-out infinite;
|
|
87
|
+
}
|
|
88
|
+
.wp-badge[data-pulse] { position: relative; }
|
|
89
|
+
.wp-badge--corner[data-pulse] { position: absolute; }
|
|
90
|
+
@keyframes wp-badge-pulse {
|
|
91
|
+
from { opacity: 0.6; transform: scale(1); }
|
|
92
|
+
to { opacity: 0; transform: scale(2.4); }
|
|
93
|
+
}
|
|
94
|
+
@media (prefers-reduced-motion: reduce) {
|
|
95
|
+
.wp-badge[data-pulse]::after { animation: none; display: none; }
|
|
96
|
+
}
|
|
97
|
+
</style>
|
package/src/Banner.vue
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Banner: an inline message in a tone, with an optional title, actions and
|
|
4
|
+
* a close button. It sits in the flow of the page (not floating), above or
|
|
5
|
+
* inside whatever it talks about. Works on the canvas and in the manager.
|
|
6
|
+
*
|
|
7
|
+
* <Banner tone="warn" title="2 modules are out of date">
|
|
8
|
+
* Update them before you publish.
|
|
9
|
+
* <template #actions><Button size="sm">Update</Button></template>
|
|
10
|
+
* </Banner>
|
|
11
|
+
*
|
|
12
|
+
* A danger banner is announced at once (`role="alert"`); the others are
|
|
13
|
+
* read politely (`role="status"`). Neither takes focus. `note` is for text
|
|
14
|
+
* that is part of the page (a docs tip): `role="note"`, never announced.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, useSlots } from "vue";
|
|
17
|
+
import CloseButton from "./CloseButton.vue";
|
|
18
|
+
|
|
19
|
+
export type BannerTone = "neutral" | "info" | "success" | "warn" | "danger" | "accent";
|
|
20
|
+
|
|
21
|
+
const TONE_ICON: Record<BannerTone, string> = {
|
|
22
|
+
neutral: "pi-info-circle",
|
|
23
|
+
info: "pi-info-circle",
|
|
24
|
+
success: "pi-check-circle",
|
|
25
|
+
warn: "pi-exclamation-triangle",
|
|
26
|
+
danger: "pi-times-circle",
|
|
27
|
+
accent: "pi-sparkles",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const props = withDefaults(defineProps<{
|
|
31
|
+
tone?: BannerTone | undefined;
|
|
32
|
+
title?: string | undefined;
|
|
33
|
+
/** A PrimeIcons class to use instead of the tone's icon; `""` for none. */
|
|
34
|
+
icon?: string | undefined;
|
|
35
|
+
size?: "sm" | "md" | undefined;
|
|
36
|
+
/** Show a close button; it emits `dismiss`. */
|
|
37
|
+
dismissible?: boolean | undefined;
|
|
38
|
+
/** The close button's name. */
|
|
39
|
+
dismissLabel?: string | undefined;
|
|
40
|
+
/** Part of the page, not news: `role="note"` instead of a live region. */
|
|
41
|
+
note?: boolean | undefined;
|
|
42
|
+
}>(), { tone: "info", size: "md", icon: undefined, dismissLabel: "Dismiss" });
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{ (e: "dismiss"): void }>();
|
|
45
|
+
const slots = useSlots();
|
|
46
|
+
|
|
47
|
+
const iconClass = computed(() => props.icon ?? TONE_ICON[props.tone]);
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<div
|
|
52
|
+
class="wp-banner"
|
|
53
|
+
:data-tone="tone"
|
|
54
|
+
:data-size="size"
|
|
55
|
+
:role="note ? 'note' : tone === 'danger' ? 'alert' : 'status'"
|
|
56
|
+
>
|
|
57
|
+
<i v-if="iconClass" class="pi wp-banner__icon" :class="iconClass" aria-hidden="true" />
|
|
58
|
+
<div class="wp-banner__body">
|
|
59
|
+
<p v-if="title" class="wp-banner__title">{{ title }}</p>
|
|
60
|
+
<div v-if="slots.default" class="wp-banner__text"><slot /></div>
|
|
61
|
+
</div>
|
|
62
|
+
<div v-if="slots.actions" class="wp-banner__actions"><slot name="actions" /></div>
|
|
63
|
+
<CloseButton
|
|
64
|
+
v-if="dismissible"
|
|
65
|
+
class="wp-banner__close"
|
|
66
|
+
size="sm"
|
|
67
|
+
:label="dismissLabel"
|
|
68
|
+
@click="emit('dismiss')"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style scoped>
|
|
74
|
+
.wp-banner {
|
|
75
|
+
--_banner-tone: var(--wp-info);
|
|
76
|
+
display: flex;
|
|
77
|
+
align-items: flex-start;
|
|
78
|
+
gap: var(--wp-space-4);
|
|
79
|
+
min-width: 0;
|
|
80
|
+
padding: var(--wp-space-4) var(--wp-space-5);
|
|
81
|
+
border: 1px solid color-mix(in srgb, var(--_banner-tone) 38%, var(--wp-border));
|
|
82
|
+
border-radius: var(--wp-radius-md);
|
|
83
|
+
background: color-mix(in srgb, var(--_banner-tone) 10%, var(--wp-bg-1));
|
|
84
|
+
color: var(--wp-text);
|
|
85
|
+
font: 400 var(--wp-text-sm)/1.45 var(--wp-font-sans);
|
|
86
|
+
}
|
|
87
|
+
.wp-banner[data-tone="neutral"] { --_banner-tone: var(--wp-text-muted); }
|
|
88
|
+
.wp-banner[data-tone="success"] { --_banner-tone: var(--wp-success); }
|
|
89
|
+
.wp-banner[data-tone="warn"] { --_banner-tone: var(--wp-warn); }
|
|
90
|
+
.wp-banner[data-tone="danger"] { --_banner-tone: var(--wp-danger); }
|
|
91
|
+
.wp-banner[data-tone="accent"] { --_banner-tone: var(--wp-accent-500); }
|
|
92
|
+
.wp-banner[data-size="sm"] {
|
|
93
|
+
gap: var(--wp-space-3);
|
|
94
|
+
padding: var(--wp-space-3) var(--wp-space-4);
|
|
95
|
+
border-radius: var(--wp-radius);
|
|
96
|
+
font-size: var(--wp-text-xs);
|
|
97
|
+
}
|
|
98
|
+
.wp-banner__icon {
|
|
99
|
+
flex: none;
|
|
100
|
+
/* Centre the icon on the first line of text. */
|
|
101
|
+
line-height: 1.45;
|
|
102
|
+
font-size: 1.1em;
|
|
103
|
+
color: var(--_banner-tone);
|
|
104
|
+
}
|
|
105
|
+
.wp-banner[data-tone="accent"] .wp-banner__icon { color: var(--wp-accent-text); }
|
|
106
|
+
.wp-banner__body { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
|
107
|
+
.wp-banner__title { margin: 0; font-weight: 600; }
|
|
108
|
+
.wp-banner__text { color: var(--wp-text-muted); overflow-wrap: anywhere; }
|
|
109
|
+
.wp-banner__body > .wp-banner__text:only-child { color: var(--wp-text); }
|
|
110
|
+
.wp-banner__text :deep(p) { margin: 0; }
|
|
111
|
+
.wp-banner__text :deep(p + p) { margin-top: var(--wp-space-2); }
|
|
112
|
+
.wp-banner__actions { display: inline-flex; align-items: center; gap: var(--wp-space-3); flex: none; align-self: center; }
|
|
113
|
+
.wp-banner__close { flex: none; margin: -2px calc(var(--wp-space-2) * -1) -2px 0; }
|
|
114
|
+
@media (max-width: 520px) {
|
|
115
|
+
.wp-banner { flex-wrap: wrap; }
|
|
116
|
+
.wp-banner__actions { order: 3; width: 100%; padding-left: calc(1.1em + var(--wp-space-4)); }
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Bookmark a post: a toggle Button with a filled bookmark when on. The app
|
|
4
|
+
* owns the request; this only shows the state and emits `toggle`.
|
|
5
|
+
*
|
|
6
|
+
* <BookmarkButton :bookmarked="post.bookmarked_by_me" :disabled="pending" @toggle="bookmark" />
|
|
7
|
+
*/
|
|
8
|
+
import Button from "./Button.vue";
|
|
9
|
+
|
|
10
|
+
withDefaults(defineProps<{
|
|
11
|
+
bookmarked: boolean;
|
|
12
|
+
disabled?: boolean | undefined;
|
|
13
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
14
|
+
}>(), { disabled: false, size: "md" });
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{ (e: "toggle"): void }>();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<Button
|
|
21
|
+
:icon="bookmarked ? 'bookmark-fill' : 'bookmark'"
|
|
22
|
+
:pressed="bookmarked"
|
|
23
|
+
:disabled="disabled"
|
|
24
|
+
:size="size"
|
|
25
|
+
@click="emit('toggle')"
|
|
26
|
+
>{{ bookmarked ? "Bookmarked" : "Bookmark" }}</Button>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One line saying what a bundle holds: a box mark, "3 modules" and their
|
|
4
|
+
* names joined by middots.
|
|
5
|
+
*
|
|
6
|
+
* <BundleMeta :count="3" :names="['palette', 'mood', 'style']" />
|
|
7
|
+
*/
|
|
8
|
+
import Icon from "./Icon.vue";
|
|
9
|
+
|
|
10
|
+
defineProps<{
|
|
11
|
+
count: number;
|
|
12
|
+
names?: string[] | undefined;
|
|
13
|
+
}>();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div class="wp-bundle-meta">
|
|
18
|
+
<span class="wp-bundle-meta__icon" aria-hidden="true"><Icon name="box" :size="10" /></span>
|
|
19
|
+
<span class="wp-bundle-meta__text">
|
|
20
|
+
<span class="wp-bundle-meta__count">{{ count }} {{ count === 1 ? "module" : "modules" }}</span>
|
|
21
|
+
<template v-for="(n, i) in names ?? []" :key="i">
|
|
22
|
+
<span class="wp-bundle-meta__sep">·</span>{{ n }}
|
|
23
|
+
</template>
|
|
24
|
+
</span>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<style scoped>
|
|
29
|
+
.wp-bundle-meta {
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: flex-start;
|
|
32
|
+
gap: 8px;
|
|
33
|
+
font-family: var(--wp-font-mono);
|
|
34
|
+
font-size: 11.5px;
|
|
35
|
+
line-height: 1.5;
|
|
36
|
+
color: var(--wp-text-dim);
|
|
37
|
+
}
|
|
38
|
+
.wp-bundle-meta__icon {
|
|
39
|
+
display: inline-grid;
|
|
40
|
+
place-items: center;
|
|
41
|
+
flex-shrink: 0;
|
|
42
|
+
width: 18px;
|
|
43
|
+
height: 18px;
|
|
44
|
+
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
|
45
|
+
background: color-mix(in oklab, var(--wp-bundle-default) 18%, transparent);
|
|
46
|
+
color: var(--wp-text-muted);
|
|
47
|
+
}
|
|
48
|
+
.wp-bundle-meta__text { flex: 1; min-width: 0; word-break: break-word; }
|
|
49
|
+
.wp-bundle-meta__count { color: var(--wp-text); font-weight: 500; }
|
|
50
|
+
.wp-bundle-meta__sep { margin: 0 4px; color: var(--wp-text-dim); }
|
|
51
|
+
</style>
|
package/src/Button.vue
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Button — single source of truth for app buttons.
|
|
4
|
+
*
|
|
5
|
+
* Variants:
|
|
6
|
+
* primary — solid accent; main CTA per surface
|
|
7
|
+
* secondary — subtle filled; default
|
|
8
|
+
* ghost — transparent with hover bg; tertiary
|
|
9
|
+
* danger — red-tinted; destructive actions
|
|
10
|
+
* link — text-only; inline CTAs
|
|
11
|
+
* outline — neutral bordered ghost (not accent: an accent outline read as
|
|
12
|
+
* a second primary)
|
|
13
|
+
* add — dashed, quiet: the "Add a row" button at the end of a list
|
|
14
|
+
*
|
|
15
|
+
* `block` stretches it to the full width of its container. `pressed` makes
|
|
16
|
+
* it a toggle (aria-pressed, accent tint while on).
|
|
17
|
+
*
|
|
18
|
+
* Sizes follow the control scale: sm 24 / md 32 (default) / lg 40.
|
|
19
|
+
*
|
|
20
|
+
* States: default · hover · active · focus-visible · disabled · loading.
|
|
21
|
+
* Disabled fades to 45% with a not-allowed cursor and no hover change.
|
|
22
|
+
* Loading keeps the variant's own colours, swaps the lead icon for the
|
|
23
|
+
* sparkle LoadingMark in the label colour, sets aria-busy and ignores clicks.
|
|
24
|
+
*
|
|
25
|
+
* Icon-only mode auto-engages when no slot content is provided (.wp-btn--icon).
|
|
26
|
+
* Pass `aria-label` for screen reader text when using icon-only mode.
|
|
27
|
+
*
|
|
28
|
+
* The styles live in kit/button.css, shared with the canvas
|
|
29
|
+
* Button twin.
|
|
30
|
+
*/
|
|
31
|
+
import { computed, useSlots } from "vue";
|
|
32
|
+
import Icon from "./Icon.vue";
|
|
33
|
+
import LoadingMark from "./LoadingMark.vue";
|
|
34
|
+
|
|
35
|
+
type Variant = "primary" | "secondary" | "ghost" | "danger" | "link" | "outline" | "add";
|
|
36
|
+
type Size = "sm" | "md" | "lg";
|
|
37
|
+
|
|
38
|
+
interface Props {
|
|
39
|
+
variant?: Variant | undefined;
|
|
40
|
+
size?: Size | undefined;
|
|
41
|
+
icon?: string | undefined;
|
|
42
|
+
iconRight?: string | undefined;
|
|
43
|
+
loading?: boolean | undefined;
|
|
44
|
+
disabled?: boolean | undefined;
|
|
45
|
+
block?: boolean | undefined;
|
|
46
|
+
/** Makes it a toggle (`aria-pressed`): on, it wears the accent tint. */
|
|
47
|
+
pressed?: boolean | undefined;
|
|
48
|
+
type?: "button" | "submit" | "reset" | undefined;
|
|
49
|
+
ariaLabel?: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
52
|
+
variant: "secondary",
|
|
53
|
+
size: "md",
|
|
54
|
+
type: "button",
|
|
55
|
+
pressed: undefined,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const emit = defineEmits<{
|
|
59
|
+
(e: "click", evt: MouseEvent): void;
|
|
60
|
+
}>();
|
|
61
|
+
|
|
62
|
+
const slots = useSlots();
|
|
63
|
+
|
|
64
|
+
const hasContent = computed(() => Boolean(slots.default));
|
|
65
|
+
|
|
66
|
+
/** Native hover tooltip for icon-only buttons. Their `aria-label` names them
|
|
67
|
+
* for screen readers, but a sighted user hovering a bare icon gets nothing —
|
|
68
|
+
* so mirror the label into `title` when there's no visible text. A button with
|
|
69
|
+
* text already says what it does, so it stays untitled. */
|
|
70
|
+
const titleAttr = computed(() =>
|
|
71
|
+
!hasContent.value && props.ariaLabel ? props.ariaLabel : undefined);
|
|
72
|
+
|
|
73
|
+
const classes = computed(() => [
|
|
74
|
+
"wp-btn",
|
|
75
|
+
`wp-btn--${props.variant}`,
|
|
76
|
+
`wp-btn--${props.size}`,
|
|
77
|
+
!hasContent.value && "wp-btn--icon",
|
|
78
|
+
props.block && "wp-btn--block",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
function onClick(e: MouseEvent) {
|
|
82
|
+
if (props.disabled || props.loading) return;
|
|
83
|
+
emit("click", e);
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<button
|
|
89
|
+
:type="type"
|
|
90
|
+
:class="classes"
|
|
91
|
+
:disabled="disabled || loading"
|
|
92
|
+
:aria-label="ariaLabel"
|
|
93
|
+
:title="titleAttr"
|
|
94
|
+
:aria-busy="loading || undefined"
|
|
95
|
+
:aria-pressed="pressed === undefined ? undefined : pressed"
|
|
96
|
+
@click="onClick"
|
|
97
|
+
>
|
|
98
|
+
<LoadingMark v-if="loading" variant="sparkle" mono :size="size === 'lg' ? 16 : 14" decorative />
|
|
99
|
+
<Icon v-else-if="icon" :name="icon" />
|
|
100
|
+
<span v-if="hasContent" class="wp-btn__label"><slot /></span>
|
|
101
|
+
<Icon v-if="iconRight" :name="iconRight" />
|
|
102
|
+
</button>
|
|
103
|
+
</template>
|