@dumiflex/wp-kit 0.0.0-stage → 0.4.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 +77 -0
- package/LICENSE +674 -0
- package/README.md +72 -2
- package/package.json +76 -4
- 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 +109 -0
- package/src/Chip.vue +163 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +188 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +331 -0
- package/src/ConfirmDialog.vue +165 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +386 -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/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +175 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +78 -0
- package/src/ModalShell.vue +109 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +180 -0
- package/src/PostCard.vue +210 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +192 -0
- package/src/Select.vue +644 -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 +297 -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 +104 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +178 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +93 -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/field.css +50 -0
- package/src/flip.ts +237 -0
- package/src/floating-layer.ts +48 -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/kind-colour.ts +28 -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 +2896 -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/src/drop-zone.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File checks for DropZone: does a file (or a dragged item, whose name is
|
|
3
|
+
* not known until the drop) match an `accept` list like the file input's
|
|
4
|
+
* ("image/*,.txt,application/json")?
|
|
5
|
+
*/
|
|
6
|
+
export function acceptList(accept: string | undefined): string[] {
|
|
7
|
+
return (accept ?? "").split(",").map((a) => a.trim().toLowerCase()).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function typeMatches(type: string, rule: string): boolean {
|
|
11
|
+
if (!type) return false;
|
|
12
|
+
const t = type.toLowerCase();
|
|
13
|
+
return rule.endsWith("/*") ? t.startsWith(rule.slice(0, -1)) : t === rule;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A dropped file: by its extension or its type. An empty list takes all. */
|
|
17
|
+
export function fileAccepted(file: { name: string; type: string }, accept: string | undefined): boolean {
|
|
18
|
+
const rules = acceptList(accept);
|
|
19
|
+
if (!rules.length) return true;
|
|
20
|
+
const name = file.name.toLowerCase();
|
|
21
|
+
return rules.some((r) => (r.startsWith(".") ? name.endsWith(r) : typeMatches(file.type, r)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A dragged item, before the drop: only its type is known. Returns false
|
|
26
|
+
* only when it surely won't be taken; an item with no type, or a list that
|
|
27
|
+
* names extensions (which we can't see yet), gets the benefit of the doubt.
|
|
28
|
+
*/
|
|
29
|
+
export function dragItemMaybeAccepted(type: string, accept: string | undefined): boolean {
|
|
30
|
+
const rules = acceptList(accept);
|
|
31
|
+
if (!rules.length || !type) return true;
|
|
32
|
+
if (rules.some((r) => r.startsWith("."))) return true;
|
|
33
|
+
return rules.some((r) => typeMatches(type, r));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** "2.4 MB", "820 KB". */
|
|
37
|
+
export function formatBytes(n: number): string {
|
|
38
|
+
if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(n >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
|
|
39
|
+
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
|
40
|
+
return `${n} B`;
|
|
41
|
+
}
|
package/src/field.css
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* Field box: the one look of every text-like field in the kit (Input,
|
|
2
|
+
* Textarea and Select draw the same box from theme/tokens.css' `.wp-input`;
|
|
3
|
+
* SearchInput, WpNumber, Combobox, TagInput, ColorField and WpSelect pull this
|
|
4
|
+
* sheet in scoped). It reads only the SHARED_TOKENS names, so a field wears
|
|
5
|
+
* the manager colours on a page and the canvas colours in a node.
|
|
6
|
+
*
|
|
7
|
+
* <div class="wp-field-box wp-field-box--sm" …>
|
|
8
|
+
*
|
|
9
|
+
* Heights follow the control scale (sm 24 / md 32 / lg 40) as a min-height,
|
|
10
|
+
* so a field that wraps (Combobox, TagInput chips) can grow; a single-line
|
|
11
|
+
* field sets `height: var(--wp-field-h)` itself. A piece keeps its own
|
|
12
|
+
* layout (display, padding, gap) and font family (WpNumber and ColorField
|
|
13
|
+
* are mono); the box sets everything you see around it.
|
|
14
|
+
*
|
|
15
|
+
* States: hover (stronger border), focus-within (accent border + focus
|
|
16
|
+
* ring), invalid (`.is-invalid`, `[data-invalid]` or `aria-invalid`),
|
|
17
|
+
* disabled (`.is-disabled`, `[data-disabled]` or `:disabled`: 45%).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
.wp-field-box {
|
|
21
|
+
--wp-field-h: var(--wp-control-h-md);
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
min-height: var(--wp-field-h);
|
|
24
|
+
background: var(--wp-bg-2);
|
|
25
|
+
color: var(--wp-text);
|
|
26
|
+
border: 1px solid var(--wp-border);
|
|
27
|
+
border-radius: var(--wp-radius-sm);
|
|
28
|
+
font-size: 12.5px;
|
|
29
|
+
transition: border-color var(--wp-motion-quick), box-shadow var(--wp-motion-quick), background var(--wp-motion-quick);
|
|
30
|
+
}
|
|
31
|
+
.wp-field-box--sm { --wp-field-h: var(--wp-control-h-sm); font-size: 12px; }
|
|
32
|
+
.wp-field-box--lg { --wp-field-h: var(--wp-control-h-lg); font-size: 13.5px; }
|
|
33
|
+
|
|
34
|
+
.wp-field-box:hover { border-color: var(--wp-border-strong); }
|
|
35
|
+
.wp-field-box:focus-within,
|
|
36
|
+
.wp-field-box:focus-visible {
|
|
37
|
+
outline: none;
|
|
38
|
+
border-color: var(--wp-accent-500);
|
|
39
|
+
box-shadow: var(--wp-focus-ring);
|
|
40
|
+
background: var(--wp-bg-1);
|
|
41
|
+
}
|
|
42
|
+
.wp-field-box.is-invalid,
|
|
43
|
+
.wp-field-box[data-invalid],
|
|
44
|
+
.wp-field-box[aria-invalid="true"] { border-color: var(--wp-error-border); }
|
|
45
|
+
.wp-field-box.is-disabled,
|
|
46
|
+
.wp-field-box[data-disabled],
|
|
47
|
+
.wp-field-box:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
48
|
+
.wp-field-box.is-disabled:hover,
|
|
49
|
+
.wp-field-box[data-disabled]:hover,
|
|
50
|
+
.wp-field-box:disabled:hover { border-color: var(--wp-border); }
|
package/src/flip.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual FLIP-move helper + reduce-motion gate. Shared across every
|
|
3
|
+
* row-list surface in the extension — moved from
|
|
4
|
+
* `src/components/context/bundles/flip.ts` (which was bundle-coupled
|
|
5
|
+
* by directory but not by code) so InjectorRow + future row UIs can
|
|
6
|
+
* reuse the same animation primitives instead of reinventing them.
|
|
7
|
+
*
|
|
8
|
+
* Used by ContextWidget's drop / picker-add / trash-remove paths to
|
|
9
|
+
* animate row reordering, plus cross-container fade-slide and bundle
|
|
10
|
+
* collapse/expand. Pure functions — no Vue imports, unit-testable.
|
|
11
|
+
*
|
|
12
|
+
* Reduce-motion contract: every helper checks `shouldAnimate()` which
|
|
13
|
+
* returns false when `.wp-a11y-no-motion` is on `document.body`. CSS
|
|
14
|
+
* transitions/animations are handled by a separate blanket rule in
|
|
15
|
+
* src/components/shared/a11y.css; this module handles the JS side.
|
|
16
|
+
*
|
|
17
|
+
* Motion constants mirror the CSS tokens in
|
|
18
|
+
* src/components/shared/theme.css (--wp-motion-*). Keep these in sync.
|
|
19
|
+
*
|
|
20
|
+
* Class-name customization: every animation helper accepts an
|
|
21
|
+
* `opts.classes` field so different consumers can use their own
|
|
22
|
+
* BEM-style modifier names (e.g. ModuleRow uses `wp-module--*`,
|
|
23
|
+
* InjectorRow uses `wp-inj-row--*`). Defaults preserve the original
|
|
24
|
+
* Context/Bundle behavior so ContextWidget keeps working without
|
|
25
|
+
* any callsite change.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const MOTION_COLLAPSE_MS = 450;
|
|
29
|
+
export const MOTION_FLIP_MS = 280;
|
|
30
|
+
export const MOTION_FADE_MS = 180;
|
|
31
|
+
export const MOTION_PULSE_MS = 420;
|
|
32
|
+
export const MOTION_STAGGER_MS = 60;
|
|
33
|
+
export const MOTION_CURVE_FLIP = "cubic-bezier(0.22, 1, 0.36, 1)";
|
|
34
|
+
|
|
35
|
+
type FlipOptions = { duration?: number; ease?: string };
|
|
36
|
+
|
|
37
|
+
/** Per-consumer class name vocabulary. Each animation helper applies
|
|
38
|
+
* these classes to mark rows mid-transition; the consumer's CSS
|
|
39
|
+
* defines what the classes look like. Defaults to the original
|
|
40
|
+
* ContextWidget/ModuleRow names for back-compat. */
|
|
41
|
+
export interface AnimationClasses {
|
|
42
|
+
arriving: string;
|
|
43
|
+
arrived: string;
|
|
44
|
+
leaving: string;
|
|
45
|
+
flash: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_CLASSES: AnimationClasses = {
|
|
49
|
+
arriving: "wp-module--arriving",
|
|
50
|
+
arrived: "wp-module--arrived",
|
|
51
|
+
leaving: "wp-module--leaving",
|
|
52
|
+
flash: "wp-module--flash",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Resolve user-supplied classes against the defaults so callers can
|
|
56
|
+
* override only the names that differ. Internal helper. */
|
|
57
|
+
function resolveClasses(partial?: Partial<AnimationClasses>): AnimationClasses {
|
|
58
|
+
if (!partial) return DEFAULT_CLASSES;
|
|
59
|
+
return { ...DEFAULT_CLASSES, ...partial };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether motion should animate. False when user has reduce-motion ON. */
|
|
63
|
+
export function shouldAnimate(): boolean {
|
|
64
|
+
return !document.body.classList.contains("wp-a11y-no-motion");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Capture each child's bounding rect, keyed by the caller's key function. */
|
|
68
|
+
export function captureRects(
|
|
69
|
+
container: HTMLElement,
|
|
70
|
+
key: (el: HTMLElement) => string | null,
|
|
71
|
+
): Map<string, DOMRect> {
|
|
72
|
+
const out = new Map<string, DOMRect>();
|
|
73
|
+
for (const child of Array.from(container.children)) {
|
|
74
|
+
const el = child as HTMLElement;
|
|
75
|
+
const k = key(el);
|
|
76
|
+
if (k != null) out.set(k, el.getBoundingClientRect());
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* After a DOM mutation, apply inverse-transform to children whose
|
|
83
|
+
* position changed, then transition back to identity. Skip silently
|
|
84
|
+
* when reduce-motion is on.
|
|
85
|
+
*/
|
|
86
|
+
export function applyFlip(
|
|
87
|
+
container: HTMLElement,
|
|
88
|
+
before: Map<string, DOMRect>,
|
|
89
|
+
key: (el: HTMLElement) => string | null,
|
|
90
|
+
opt: FlipOptions = {},
|
|
91
|
+
): void {
|
|
92
|
+
if (!shouldAnimate()) return;
|
|
93
|
+
const dur = opt.duration ?? MOTION_FLIP_MS;
|
|
94
|
+
const ease = opt.ease ?? MOTION_CURVE_FLIP;
|
|
95
|
+
for (const child of Array.from(container.children)) {
|
|
96
|
+
const el = child as HTMLElement;
|
|
97
|
+
const k = key(el);
|
|
98
|
+
if (k == null) continue;
|
|
99
|
+
const beforeRect = before.get(k);
|
|
100
|
+
if (!beforeRect) continue;
|
|
101
|
+
const afterRect = el.getBoundingClientRect();
|
|
102
|
+
const dx = beforeRect.left - afterRect.left;
|
|
103
|
+
const dy = beforeRect.top - afterRect.top;
|
|
104
|
+
if (dx === 0 && dy === 0) continue;
|
|
105
|
+
el.style.transition = "none";
|
|
106
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
107
|
+
requestAnimationFrame(() => {
|
|
108
|
+
el.style.transition = `transform ${dur}ms ${ease}`;
|
|
109
|
+
el.style.transform = "translate(0, 0)";
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Mutate state then animate the newly-inserted element (matched by uid)
|
|
116
|
+
* with --arriving → --arrived class transition. The arriving class is
|
|
117
|
+
* applied after the mutation + a microtask so the browser registers
|
|
118
|
+
* the "from" state; arrived flips the next frame so the transition fires.
|
|
119
|
+
* No-op (mutate-only) when reduce-motion is on.
|
|
120
|
+
*/
|
|
121
|
+
export async function withEnterAnimation(
|
|
122
|
+
uid: string,
|
|
123
|
+
container: HTMLElement,
|
|
124
|
+
mutate: () => void,
|
|
125
|
+
opts?: { classes?: Partial<AnimationClasses> },
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
mutate();
|
|
128
|
+
if (!shouldAnimate()) return;
|
|
129
|
+
const cls = resolveClasses(opts?.classes);
|
|
130
|
+
// Wait one rAF so Vue commits the mutation to DOM before we query.
|
|
131
|
+
// A bare `await Promise.resolve()` schedules a microtask that can
|
|
132
|
+
// race Vue's own microtask-based reactivity scheduler — rAF runs
|
|
133
|
+
// after the microtask queue drains and before paint.
|
|
134
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
|
135
|
+
const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
|
|
136
|
+
if (!el) return;
|
|
137
|
+
el.classList.add(cls.arriving);
|
|
138
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
|
139
|
+
el.classList.add(cls.arrived);
|
|
140
|
+
const cleanup = (): void => {
|
|
141
|
+
el.classList.remove(cls.arriving, cls.arrived);
|
|
142
|
+
el.removeEventListener("transitionend", cleanup);
|
|
143
|
+
};
|
|
144
|
+
el.addEventListener("transitionend", cleanup, { once: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Animate a batch of newly-inserted elements (matched by uid array)
|
|
149
|
+
* with --arriving → --arrived class lifecycle. Caller has already
|
|
150
|
+
* mutated state and is responsible for waiting nextTick if needed —
|
|
151
|
+
* this helper waits one rAF before applying classes so Vue's commit
|
|
152
|
+
* has landed.
|
|
153
|
+
*
|
|
154
|
+
* Two-pass design: apply all --arriving classes first (so every row
|
|
155
|
+
* is at the from-state simultaneously), then apply all --arrived
|
|
156
|
+
* classes on the next frame (so they all transition together). Avoids
|
|
157
|
+
* a staircase effect where rows animate sequentially.
|
|
158
|
+
*
|
|
159
|
+
* No-op when reduce-motion is on.
|
|
160
|
+
*/
|
|
161
|
+
export async function animateEnterBatch(
|
|
162
|
+
uids: readonly (string | null | undefined)[],
|
|
163
|
+
container: HTMLElement,
|
|
164
|
+
opts?: { classes?: Partial<AnimationClasses> },
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
if (!shouldAnimate()) return;
|
|
167
|
+
const cls = resolveClasses(opts?.classes);
|
|
168
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
|
169
|
+
const elements: HTMLElement[] = [];
|
|
170
|
+
for (const uid of uids) {
|
|
171
|
+
if (!uid) continue;
|
|
172
|
+
const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
|
|
173
|
+
if (el) elements.push(el);
|
|
174
|
+
}
|
|
175
|
+
if (elements.length === 0) return;
|
|
176
|
+
for (const el of elements) el.classList.add(cls.arriving);
|
|
177
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
|
178
|
+
for (const el of elements) {
|
|
179
|
+
el.classList.add(cls.arrived);
|
|
180
|
+
const cleanup = (): void => {
|
|
181
|
+
el.classList.remove(cls.arriving, cls.arrived);
|
|
182
|
+
el.removeEventListener("transitionend", cleanup);
|
|
183
|
+
};
|
|
184
|
+
el.addEventListener("transitionend", cleanup, { once: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Flash a batch of rows with the existing wp-module--flash green ring
|
|
190
|
+
* keyframe (1500ms) — used to indicate library-side mutations (refresh,
|
|
191
|
+
* reset, fork). Staggered so bulk refresh cascades. No-op when reduce-
|
|
192
|
+
* motion is on.
|
|
193
|
+
*/
|
|
194
|
+
const FLASH_DURATION_MS = MOTION_PULSE_MS; // 420ms — matches drop pulse timing
|
|
195
|
+
export async function flashRows(
|
|
196
|
+
uids: readonly (string | null | undefined)[],
|
|
197
|
+
container: HTMLElement,
|
|
198
|
+
staggerMs: number = MOTION_STAGGER_MS,
|
|
199
|
+
opts?: { classes?: Partial<AnimationClasses> },
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
if (!shouldAnimate()) return;
|
|
202
|
+
const cls = resolveClasses(opts?.classes);
|
|
203
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()));
|
|
204
|
+
for (let i = 0; i < uids.length; i++) {
|
|
205
|
+
const uid = uids[i];
|
|
206
|
+
if (!uid) continue;
|
|
207
|
+
const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
|
|
208
|
+
if (!el) continue;
|
|
209
|
+
const delay = i * staggerMs;
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
el.classList.add(cls.flash);
|
|
212
|
+
setTimeout(() => el.classList.remove(cls.flash), FLASH_DURATION_MS);
|
|
213
|
+
}, delay);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Animate an element (matched by uid) with --leaving class, wait for the
|
|
219
|
+
* fade to complete, then run mutate to remove from state. Mutate is
|
|
220
|
+
* called immediately when reduce-motion is on (no class added).
|
|
221
|
+
*/
|
|
222
|
+
export async function withLeaveAnimation(
|
|
223
|
+
uid: string,
|
|
224
|
+
container: HTMLElement,
|
|
225
|
+
mutate: () => void,
|
|
226
|
+
opts?: { classes?: Partial<AnimationClasses> },
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
if (!shouldAnimate()) {
|
|
229
|
+
mutate();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const cls = resolveClasses(opts?.classes);
|
|
233
|
+
const el = container.querySelector<HTMLElement>(`[data-uid="${uid}"]`);
|
|
234
|
+
if (el) el.classList.add(cls.leaving);
|
|
235
|
+
await new Promise<void>(r => setTimeout(r, MOTION_FADE_MS));
|
|
236
|
+
mutate();
|
|
237
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lets a floating piece (a Select's list, a menu, a nested Popover) that is
|
|
3
|
+
* opened from inside a Popover count as part of it. Those pieces teleport to
|
|
4
|
+
* <body>, outside the Popover's panel, so without this a click on one is a
|
|
5
|
+
* click outside the Popover (which closes it) and the Popover's Escape
|
|
6
|
+
* handler, listening first, closes it before the inner list can.
|
|
7
|
+
*
|
|
8
|
+
* A Popover calls `provideFloatingLayer()`; a floating piece calls
|
|
9
|
+
* `useFloatingChild(() => el)` with its floating element (null while shut).
|
|
10
|
+
*/
|
|
11
|
+
import { inject, onBeforeUnmount, provide, type InjectionKey } from "vue";
|
|
12
|
+
|
|
13
|
+
type Getter = () => HTMLElement | null;
|
|
14
|
+
|
|
15
|
+
interface FloatingLayer {
|
|
16
|
+
add(get: Getter): () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FLOATING_LAYER: InjectionKey<FloatingLayer> = Symbol("wp-floating-layer");
|
|
20
|
+
|
|
21
|
+
export interface OwnedFloating {
|
|
22
|
+
/** True when the event path runs through an open child. */
|
|
23
|
+
owns(path: readonly EventTarget[]): boolean;
|
|
24
|
+
/** True while any child has its floating element open. */
|
|
25
|
+
childOpen(): boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function provideFloatingLayer(): OwnedFloating {
|
|
29
|
+
const children = new Set<Getter>();
|
|
30
|
+
provide(FLOATING_LAYER, {
|
|
31
|
+
add(get) {
|
|
32
|
+
children.add(get);
|
|
33
|
+
return () => { children.delete(get); };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const open = (): HTMLElement[] => [...children].map((g) => g()).filter((el): el is HTMLElement => el !== null);
|
|
37
|
+
return {
|
|
38
|
+
owns: (path) => open().some((el) => path.includes(el)),
|
|
39
|
+
childOpen: () => open().length > 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useFloatingChild(get: Getter): void {
|
|
44
|
+
const layer = inject(FLOATING_LAYER, null);
|
|
45
|
+
if (!layer) return;
|
|
46
|
+
const remove = layer.add(get);
|
|
47
|
+
onBeforeUnmount(remove);
|
|
48
|
+
}
|
package/src/floating.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a floating panel (popover, menu, tooltip) goes next to its anchor:
|
|
3
|
+
* on the preferred side when it fits, on the other side when it doesn't,
|
|
4
|
+
* and always kept inside the viewport. Pure maths, no DOM, so it is the
|
|
5
|
+
* same on the canvas and in the manager. `Popover`, `ContextMenu` and the
|
|
6
|
+
* tooltip directive all place themselves through it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Side = "top" | "bottom" | "left" | "right";
|
|
10
|
+
export type Align = "start" | "center" | "end";
|
|
11
|
+
export type Placement = Side | `${Side}-${Exclude<Align, "center">}`;
|
|
12
|
+
|
|
13
|
+
export interface Rect {
|
|
14
|
+
left: number;
|
|
15
|
+
top: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Size {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PlaceOptions {
|
|
26
|
+
placement?: Placement;
|
|
27
|
+
/** Space between the anchor and the panel. */
|
|
28
|
+
gap?: number;
|
|
29
|
+
/** Space the panel keeps from the viewport edge. */
|
|
30
|
+
margin?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Placed {
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
/** The side it ended up on, after any flip. */
|
|
37
|
+
side: Side;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
|
|
41
|
+
|
|
42
|
+
export function splitPlacement(p: Placement): { side: Side; align: Align } {
|
|
43
|
+
const [side, align] = p.split("-") as [Side, Exclude<Align, "center"> | undefined];
|
|
44
|
+
return { side, align: align ?? "center" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function room(side: Side, a: Rect, view: Size, gap: number, margin: number): number {
|
|
48
|
+
switch (side) {
|
|
49
|
+
case "top": return a.top - gap - margin;
|
|
50
|
+
case "bottom": return view.height - (a.top + a.height) - gap - margin;
|
|
51
|
+
case "left": return a.left - gap - margin;
|
|
52
|
+
case "right": return view.width - (a.left + a.width) - gap - margin;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clamp(v: number, min: number, max: number): number {
|
|
57
|
+
return max < min ? min : Math.min(Math.max(v, min), max);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function placeFloating(anchor: Rect, panel: Size, view: Size, opts: PlaceOptions = {}): Placed {
|
|
61
|
+
const gap = opts.gap ?? 6;
|
|
62
|
+
const margin = opts.margin ?? 8;
|
|
63
|
+
const { side: want, align } = splitPlacement(opts.placement ?? "bottom-start");
|
|
64
|
+
const vertical = want === "top" || want === "bottom";
|
|
65
|
+
const need = vertical ? panel.height : panel.width;
|
|
66
|
+
|
|
67
|
+
// Flip only when the other side has more room; a panel too big for both
|
|
68
|
+
// stays on the side with more space and gets clamped.
|
|
69
|
+
let side = want;
|
|
70
|
+
if (room(want, anchor, view, gap, margin) < need) {
|
|
71
|
+
const other = OPPOSITE[want];
|
|
72
|
+
if (room(other, anchor, view, gap, margin) > room(want, anchor, view, gap, margin)) side = other;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let x: number;
|
|
76
|
+
let y: number;
|
|
77
|
+
if (vertical) {
|
|
78
|
+
y = side === "bottom" ? anchor.top + anchor.height + gap : anchor.top - gap - panel.height;
|
|
79
|
+
x = align === "start" ? anchor.left
|
|
80
|
+
: align === "end" ? anchor.left + anchor.width - panel.width
|
|
81
|
+
: anchor.left + (anchor.width - panel.width) / 2;
|
|
82
|
+
} else {
|
|
83
|
+
x = side === "right" ? anchor.left + anchor.width + gap : anchor.left - gap - panel.width;
|
|
84
|
+
y = align === "start" ? anchor.top
|
|
85
|
+
: align === "end" ? anchor.top + anchor.height - panel.height
|
|
86
|
+
: anchor.top + (anchor.height - panel.height) / 2;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
x: Math.round(clamp(x, margin, view.width - margin - panel.width)),
|
|
91
|
+
y: Math.round(clamp(y, margin, view.height - margin - panel.height)),
|
|
92
|
+
side,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The viewport, for callers that have a window. */
|
|
97
|
+
export function viewportSize(): Size {
|
|
98
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
99
|
+
}
|
package/src/hsv.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HSV / RGB / hex color conversion utilities.
|
|
3
|
+
*
|
|
4
|
+
* `h` in `[0, 360)`, `s` and `v` in `[0, 1]`. Hex strings are `#rrggbb`
|
|
5
|
+
* lowercase. Functions are tolerant of out-of-range input — they clamp
|
|
6
|
+
* rather than throw, which keeps the picker UI robust under fast drags.
|
|
7
|
+
*
|
|
8
|
+
* Lives in shared/ so both the manager-side ColorPicker / TweaksPanel
|
|
9
|
+
* AND the canvas-side bundle modal can drive the same HsvPicker
|
|
10
|
+
* without a cross-package import.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Hsv {
|
|
14
|
+
h: number;
|
|
15
|
+
s: number;
|
|
16
|
+
v: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Rgb {
|
|
20
|
+
r: number;
|
|
21
|
+
g: number;
|
|
22
|
+
b: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HEX_PATTERN = /^#?([0-9a-fA-F]{6})$/;
|
|
26
|
+
|
|
27
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
28
|
+
if (Number.isNaN(n)) return lo;
|
|
29
|
+
return Math.min(hi, Math.max(lo, n));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hsvToRgb(hsv: Hsv): Rgb {
|
|
33
|
+
const H = (((hsv.h % 360) + 360) % 360);
|
|
34
|
+
const S = clamp(hsv.s, 0, 1);
|
|
35
|
+
const V = clamp(hsv.v, 0, 1);
|
|
36
|
+
|
|
37
|
+
const C = V * S;
|
|
38
|
+
const X = C * (1 - Math.abs(((H / 60) % 2) - 1));
|
|
39
|
+
const M = V - C;
|
|
40
|
+
let r1 = 0;
|
|
41
|
+
let g1 = 0;
|
|
42
|
+
let b1 = 0;
|
|
43
|
+
if (H < 60) { r1 = C; g1 = X; b1 = 0; }
|
|
44
|
+
else if (H < 120) { r1 = X; g1 = C; b1 = 0; }
|
|
45
|
+
else if (H < 180) { r1 = 0; g1 = C; b1 = X; }
|
|
46
|
+
else if (H < 240) { r1 = 0; g1 = X; b1 = C; }
|
|
47
|
+
else if (H < 300) { r1 = X; g1 = 0; b1 = C; }
|
|
48
|
+
else { r1 = C; g1 = 0; b1 = X; }
|
|
49
|
+
return {
|
|
50
|
+
r: Math.round((r1 + M) * 255),
|
|
51
|
+
g: Math.round((g1 + M) * 255),
|
|
52
|
+
b: Math.round((b1 + M) * 255),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function rgbToHsv(rgb: Rgb): Hsv {
|
|
57
|
+
const R = clamp(rgb.r, 0, 255) / 255;
|
|
58
|
+
const G = clamp(rgb.g, 0, 255) / 255;
|
|
59
|
+
const B = clamp(rgb.b, 0, 255) / 255;
|
|
60
|
+
const max = Math.max(R, G, B);
|
|
61
|
+
const min = Math.min(R, G, B);
|
|
62
|
+
const delta = max - min;
|
|
63
|
+
|
|
64
|
+
let h = 0;
|
|
65
|
+
if (delta > 0) {
|
|
66
|
+
if (max === R) h = ((G - B) / delta) % 6;
|
|
67
|
+
else if (max === G) h = (B - R) / delta + 2;
|
|
68
|
+
else h = (R - G) / delta + 4;
|
|
69
|
+
h *= 60;
|
|
70
|
+
if (h < 0) h += 360;
|
|
71
|
+
}
|
|
72
|
+
const s = max === 0 ? 0 : delta / max;
|
|
73
|
+
const v = max;
|
|
74
|
+
return { h, s, v };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function rgbToHex(rgb: Rgb): string {
|
|
78
|
+
const part = (n: number): string => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
|
|
79
|
+
return `#${part(rgb.r)}${part(rgb.g)}${part(rgb.b)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function hexToRgb(hex: string): Rgb | null {
|
|
83
|
+
const matches = hex.trim().match(HEX_PATTERN);
|
|
84
|
+
if (!matches) return null;
|
|
85
|
+
const v = matches[1];
|
|
86
|
+
return {
|
|
87
|
+
r: parseInt(v.slice(0, 2), 16),
|
|
88
|
+
g: parseInt(v.slice(2, 4), 16),
|
|
89
|
+
b: parseInt(v.slice(4, 6), 16),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function hexToHsv(hex: string): Hsv | null {
|
|
94
|
+
const rgb = hexToRgb(hex);
|
|
95
|
+
return rgb ? rgbToHsv(rgb) : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function hsvToHex(hsv: Hsv): string {
|
|
99
|
+
return rgbToHex(hsvToRgb(hsv));
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated PrimeIcons glyphs offered by `IconPicker.vue`.
|
|
3
|
+
*
|
|
4
|
+
* PrimeIcons ships ~314 glyphs; this is a subset. The full set would need its
|
|
5
|
+
* own search UI, and most of it (chevrons, spinners, media transport, brand
|
|
6
|
+
* logos) means nothing as a library label. The groups below follow the kinds
|
|
7
|
+
* of things people actually categorise prompt modules by.
|
|
8
|
+
*
|
|
9
|
+
* Names are PrimeIcons ids MINUS the `pi-` prefix (`"user"`, not `"pi-user"`)
|
|
10
|
+
* — that is what the engine's `icon` column holds and what `Icon.vue` expects.
|
|
11
|
+
*
|
|
12
|
+
* In a plain module rather than inside the SFC so `icon-catalog.test.ts` can
|
|
13
|
+
* check every name against the installed `primeicons.css` without mounting a
|
|
14
|
+
* component. That test is the point: a name with no matching glyph renders as
|
|
15
|
+
* an EMPTY CELL, which looks like a layout bug rather than a typo, and `brush`
|
|
16
|
+
* shipped that way because nothing verified the list.
|
|
17
|
+
*/
|
|
18
|
+
export interface IconGroup {
|
|
19
|
+
label: string;
|
|
20
|
+
icons: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ICON_GROUPS: IconGroup[] = [
|
|
24
|
+
{
|
|
25
|
+
label: "Subject",
|
|
26
|
+
icons: ["user", "users", "heart", "star", "eye", "face-smile", "crown", "id-card"],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
// `sliders-h` stands in for the art-supply glyph PrimeIcons doesn't have —
|
|
30
|
+
// there is no brush/paint icon in the set, and `palette` already carries
|
|
31
|
+
// "art style". Sliders read as "tune the look".
|
|
32
|
+
label: "Style & look",
|
|
33
|
+
icons: ["palette", "pencil", "sliders-h", "camera", "image", "sparkles", "sun", "moon"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: "Scene",
|
|
37
|
+
icons: ["home", "building", "map", "map-marker", "globe", "compass", "car", "cloud"],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: "Structure",
|
|
41
|
+
icons: ["box", "folder", "book", "tag", "tags", "list", "sitemap", "objects-column"],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: "Signals",
|
|
45
|
+
icons: ["bolt", "flag", "bell", "shield", "lock", "wrench", "filter", "info-circle"],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Every offered glyph, flattened — for tests and any future search box. */
|
|
50
|
+
export function allCuratedIcons(): string[] {
|
|
51
|
+
return ICON_GROUPS.flatMap((g) => g.icons);
|
|
52
|
+
}
|