@zylem/ui 0.9.7 → 0.9.8
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/dist/styles.css +1 -1
- package/etc/ui.api.md +1938 -0
- package/package.json +13 -12
- package/src/components/Console/Console.tsx +5 -0
- package/src/components/MenuButton/MenuButton.css.ts +25 -0
- package/src/components/MenuButton/MenuButton.tsx +9 -0
- package/src/components/NumberField/NumberField.tsx +116 -3
- package/src/components/Property/Property.tsx +12 -3
- package/src/components/TimelineTrack/timeline-math.ts +2 -1
- package/src/components/TitleBar/TitleBar.tsx +2 -0
- package/src/components/ToolButton/ToolButton.tsx +1 -1
- package/src/components/ToolbarButton/ToolbarButton.css.ts +14 -1
- package/src/components/ToolbarButton/ToolbarButton.tsx +10 -2
- package/src/components/ToolbarDropdownMenu/ToolbarDropdownMenu.tsx +10 -1
- package/src/components/WindowControls/WindowControls.tsx +18 -5
- package/src/components/index.ts +14 -0
- package/src/icons/icon-nodes.ts +36 -0
- package/src/icons/index.ts +48 -0
- package/src/layers/LayerContext.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zylem/ui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "Design tokens, global styles, and Solid components for Zylem, built with vanilla-extract",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -50,18 +50,10 @@
|
|
|
50
50
|
},
|
|
51
51
|
"files": [
|
|
52
52
|
"dist",
|
|
53
|
+
"etc",
|
|
53
54
|
"src",
|
|
54
55
|
"vite"
|
|
55
56
|
],
|
|
56
|
-
"scripts": {
|
|
57
|
-
"build": "tsup",
|
|
58
|
-
"prepublishOnly": "NODE_ENV=production tsup",
|
|
59
|
-
"dev": "tsup --watch",
|
|
60
|
-
"showcase": "vite --host",
|
|
61
|
-
"test": "vitest run",
|
|
62
|
-
"test:watch": "vitest",
|
|
63
|
-
"lint": "biome check ."
|
|
64
|
-
},
|
|
65
57
|
"peerDependencies": {
|
|
66
58
|
"solid-js": "^1.9.9"
|
|
67
59
|
},
|
|
@@ -72,6 +64,7 @@
|
|
|
72
64
|
},
|
|
73
65
|
"devDependencies": {
|
|
74
66
|
"@biomejs/biome": "^2.3.7",
|
|
67
|
+
"@microsoft/api-extractor": "^7.55.2",
|
|
75
68
|
"@clack/prompts": "^1.6.0",
|
|
76
69
|
"@solidjs/testing-library": "^0.8.10",
|
|
77
70
|
"@vanilla-extract/esbuild-plugin": "^2.3.22",
|
|
@@ -95,5 +88,13 @@
|
|
|
95
88
|
"node": ">=22.12.0",
|
|
96
89
|
"pnpm": ">=10.32.1"
|
|
97
90
|
},
|
|
98
|
-
"
|
|
99
|
-
|
|
91
|
+
"scripts": {
|
|
92
|
+
"build": "tsup",
|
|
93
|
+
"dev": "tsup --watch",
|
|
94
|
+
"showcase": "vite --host",
|
|
95
|
+
"test": "vitest run",
|
|
96
|
+
"test:watch": "vitest",
|
|
97
|
+
"lint": "biome check .",
|
|
98
|
+
"api:extract": "tsc -p tsconfig.api.json && api-extractor run"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -19,6 +19,11 @@ export interface ConsoleProps {
|
|
|
19
19
|
/**
|
|
20
20
|
* HyperGlass console: dark low-gloss terminal field with a floating Clear
|
|
21
21
|
* action.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```tsx
|
|
25
|
+
* <Console value={text()} onInput={setText} onClear={() => setText('')} />
|
|
26
|
+
* ```
|
|
22
27
|
*/
|
|
23
28
|
export function Console(props: ConsoleProps) {
|
|
24
29
|
return (
|
|
@@ -104,3 +104,28 @@ globalStyle('.zylem-menu-button__stack', {
|
|
|
104
104
|
gap: vars.spacing.xs,
|
|
105
105
|
padding: vars.spacing.sm,
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* View cube. A short row (game camera, perspective) sits above a 3×3 square:
|
|
110
|
+
* isometric corners, orthographic edges, free camera in the middle.
|
|
111
|
+
*/
|
|
112
|
+
globalStyle('.zylem-menu-button__compass', {
|
|
113
|
+
display: 'flex',
|
|
114
|
+
flexDirection: 'column',
|
|
115
|
+
alignItems: 'flex-start',
|
|
116
|
+
gap: vars.spacing.xs,
|
|
117
|
+
padding: vars.spacing.sm,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
globalStyle('.zylem-menu-button__compass-row, .zylem-menu-button__compass-square', {
|
|
121
|
+
display: 'grid',
|
|
122
|
+
gap: vars.spacing.xs,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
globalStyle('.zylem-menu-button__compass-row', {
|
|
126
|
+
gridTemplateColumns: 'repeat(2, auto)',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
globalStyle('.zylem-menu-button__compass-square', {
|
|
130
|
+
gridTemplateColumns: 'repeat(3, auto)',
|
|
131
|
+
});
|
|
@@ -22,6 +22,12 @@ export interface MenuButtonProps {
|
|
|
22
22
|
onAction?: (() => void) | undefined;
|
|
23
23
|
/** Renders the face in its selected state. */
|
|
24
24
|
selected?: boolean | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Checkbox-like toggle for the face. Electric blue while on. A split face
|
|
27
|
+
* exposes it as `aria-pressed`, so pass `false` for a toggle that is off and
|
|
28
|
+
* the face still announces itself.
|
|
29
|
+
*/
|
|
30
|
+
on?: boolean | undefined;
|
|
25
31
|
disabled?: boolean | undefined;
|
|
26
32
|
/** Tooltip text; defaults to `label`. Pass `null` for no tooltip. */
|
|
27
33
|
tooltip?: string | null | undefined;
|
|
@@ -108,6 +114,7 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
108
114
|
aria-label={props.label}
|
|
109
115
|
disabled={props.disabled}
|
|
110
116
|
data-selected={props.selected ? '' : undefined}
|
|
117
|
+
data-on={props.on ? '' : undefined}
|
|
111
118
|
>
|
|
112
119
|
{props.children}
|
|
113
120
|
{/*
|
|
@@ -129,8 +136,10 @@ export function MenuButton(props: MenuButtonProps) {
|
|
|
129
136
|
ref={layer.anchorRef}
|
|
130
137
|
class="zylem-toolbar-btn zylem-menu-button__face"
|
|
131
138
|
aria-label={props.label}
|
|
139
|
+
aria-pressed={props.on}
|
|
132
140
|
disabled={props.disabled}
|
|
133
141
|
data-selected={props.selected ? '' : undefined}
|
|
142
|
+
data-on={props.on ? '' : undefined}
|
|
134
143
|
onClick={() => props.onAction?.()}
|
|
135
144
|
>
|
|
136
145
|
{props.children}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NumberField as KNumberField } from '@kobalte/core';
|
|
2
2
|
import ChevronDown from 'lucide-solid/icons/chevron-down';
|
|
3
3
|
import ChevronUp from 'lucide-solid/icons/chevron-up';
|
|
4
|
-
import { Show } from 'solid-js';
|
|
4
|
+
import { createEffect, createSignal, on, onCleanup, Show } from 'solid-js';
|
|
5
5
|
|
|
6
6
|
export interface NumberFieldProps {
|
|
7
7
|
label?: string;
|
|
@@ -9,7 +9,15 @@ export interface NumberFieldProps {
|
|
|
9
9
|
'aria-label'?: string;
|
|
10
10
|
error?: string;
|
|
11
11
|
value?: number;
|
|
12
|
+
/** Fires on every edit. */
|
|
12
13
|
onChange?: (value: number) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Fires on blur, Enter, and a stepper press. While set, typing stays in the
|
|
16
|
+
* field and only this reports it, so a parent that snaps or rounds the value
|
|
17
|
+
* does not fight the typing. A draft also outlasts changes to `value`, and
|
|
18
|
+
* Escape drops it; after each commit the field shows `value` again.
|
|
19
|
+
*/
|
|
20
|
+
onCommit?: (value: number) => void;
|
|
13
21
|
minValue?: number;
|
|
14
22
|
maxValue?: number;
|
|
15
23
|
step?: number;
|
|
@@ -23,15 +31,91 @@ export interface NumberFieldProps {
|
|
|
23
31
|
* HyperGlass number field: inset glass input with square jewel steppers.
|
|
24
32
|
*/
|
|
25
33
|
export function NumberField(props: NumberFieldProps) {
|
|
34
|
+
// A stepper press blurs the input before the click lands. Armed on pointer
|
|
35
|
+
// down, so that blur does not commit the pre-step value, and the click commits
|
|
36
|
+
// the stepped one.
|
|
37
|
+
let stepper = false;
|
|
38
|
+
// Enter commits, and the blur that follows it must not commit again.
|
|
39
|
+
let committed = false;
|
|
40
|
+
|
|
41
|
+
// Kobalte rewrites the input whenever `rawValue` changes, so a value that
|
|
42
|
+
// moves every frame would overwrite the typing. A draft hands it `held`.
|
|
43
|
+
const [drafting, setDrafting] = createSignal(false);
|
|
44
|
+
let held: number | undefined;
|
|
45
|
+
// A committed draft stays up until `value` changes, so the field does not
|
|
46
|
+
// flash the old value while the owner's answer is on its way. One frame is
|
|
47
|
+
// the limit, for an owner that rounds the commit back to the same value.
|
|
48
|
+
let settling = false;
|
|
49
|
+
let settleFrame = 0;
|
|
50
|
+
|
|
51
|
+
const hold = (value: number | undefined) => {
|
|
52
|
+
if (drafting() || value === undefined) return;
|
|
53
|
+
held = value;
|
|
54
|
+
setDrafting(true);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const stopSettling = () => {
|
|
58
|
+
settling = false;
|
|
59
|
+
if (settleFrame) cancelAnimationFrame(settleFrame);
|
|
60
|
+
settleFrame = 0;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const release = () => {
|
|
64
|
+
stopSettling();
|
|
65
|
+
setDrafting(false);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
createEffect(
|
|
69
|
+
on(
|
|
70
|
+
() => props.value,
|
|
71
|
+
() => {
|
|
72
|
+
if (settling) release();
|
|
73
|
+
},
|
|
74
|
+
{ defer: true },
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
onCleanup(stopSettling);
|
|
78
|
+
|
|
79
|
+
const commit = (value: number) => {
|
|
80
|
+
if (!props.onCommit) return;
|
|
81
|
+
if (Number.isNaN(value) || value === props.value) {
|
|
82
|
+
release();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// A stepper commit has no draft yet. Held at the stepped value, the field
|
|
86
|
+
// keeps showing it rather than the value it stepped from.
|
|
87
|
+
hold(value);
|
|
88
|
+
settling = true;
|
|
89
|
+
props.onCommit(value);
|
|
90
|
+
committed = true;
|
|
91
|
+
if (settling) settleFrame = requestAnimationFrame(release);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const commitInput = (input: HTMLInputElement) => {
|
|
95
|
+
const parsed = Number.parseFloat(input.value);
|
|
96
|
+
commit(Number.isFinite(parsed) ? parsed : Number.NaN);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const armStepper = () => {
|
|
100
|
+
stepper = true;
|
|
101
|
+
};
|
|
102
|
+
|
|
26
103
|
return (
|
|
27
104
|
<KNumberField.Root
|
|
28
105
|
class={props.class ? `zylem-field-root ${props.class}` : 'zylem-field-root'}
|
|
29
|
-
rawValue={props.value}
|
|
106
|
+
rawValue={drafting() ? held : props.value}
|
|
30
107
|
// Kobalte reports the parsed value on mount and after each format pass;
|
|
31
108
|
// only real edits reach the owner, so a controlled form is not marked
|
|
32
109
|
// dirty just by rendering.
|
|
33
110
|
onRawValueChange={(value) => {
|
|
34
|
-
if (
|
|
111
|
+
if (Number.isNaN(value)) return;
|
|
112
|
+
if (props.onCommit) {
|
|
113
|
+
if (!stepper) return;
|
|
114
|
+
stepper = false;
|
|
115
|
+
commit(value);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!props.onChange || value === props.value) return;
|
|
35
119
|
props.onChange(value);
|
|
36
120
|
}}
|
|
37
121
|
minValue={props.minValue}
|
|
@@ -51,16 +135,45 @@ export function NumberField(props: NumberFieldProps) {
|
|
|
51
135
|
class="zylem-field zylem-numberfield-input"
|
|
52
136
|
aria-label={props['aria-label']}
|
|
53
137
|
placeholder={props.placeholder}
|
|
138
|
+
onInput={() => {
|
|
139
|
+
committed = false;
|
|
140
|
+
if (!props.onCommit) return;
|
|
141
|
+
// Typing again while a commit settles carries on with the draft, so
|
|
142
|
+
// the value that lands does not overwrite the new keystrokes.
|
|
143
|
+
stopSettling();
|
|
144
|
+
hold(props.value);
|
|
145
|
+
}}
|
|
146
|
+
onBlur={(event) => {
|
|
147
|
+
if (!props.onCommit || stepper || committed) {
|
|
148
|
+
committed = false;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
commitInput(event.currentTarget);
|
|
152
|
+
}}
|
|
153
|
+
onKeyDown={(event) => {
|
|
154
|
+
if (!props.onCommit || event.defaultPrevented) return;
|
|
155
|
+
if (event.key === 'Enter') commitInput(event.currentTarget);
|
|
156
|
+
else if (event.key === 'Escape' && drafting()) {
|
|
157
|
+
// Claimed, so an accordion or dialog around the field leaves this
|
|
158
|
+
// Escape alone rather than collapsing or closing as well.
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
release();
|
|
161
|
+
}
|
|
162
|
+
}}
|
|
54
163
|
/>
|
|
55
164
|
<KNumberField.IncrementTrigger
|
|
56
165
|
class="zylem-numberfield-stepper zylem-numberfield-stepper--up"
|
|
57
166
|
aria-label="Increment"
|
|
167
|
+
onPointerDown={armStepper}
|
|
168
|
+
onClick={armStepper}
|
|
58
169
|
>
|
|
59
170
|
<ChevronUp aria-hidden="true" />
|
|
60
171
|
</KNumberField.IncrementTrigger>
|
|
61
172
|
<KNumberField.DecrementTrigger
|
|
62
173
|
class="zylem-numberfield-stepper zylem-numberfield-stepper--down"
|
|
63
174
|
aria-label="Decrement"
|
|
175
|
+
onPointerDown={armStepper}
|
|
176
|
+
onClick={armStepper}
|
|
64
177
|
>
|
|
65
178
|
<ChevronDown aria-hidden="true" />
|
|
66
179
|
</KNumberField.DecrementTrigger>
|
|
@@ -102,9 +102,18 @@ export function PropertyRow(props: PropertyRowProps) {
|
|
|
102
102
|
<Show
|
|
103
103
|
when={display().isClickable}
|
|
104
104
|
fallback={
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// A div when the value is elements. A NumberField root is a div, and a
|
|
106
|
+
// span would close before it, dropping the fields out of this cell.
|
|
107
|
+
<Show
|
|
108
|
+
when={props.children !== undefined}
|
|
109
|
+
fallback={
|
|
110
|
+
<span class={`zylem-property-value ${props.valueClass ?? ''}`}>
|
|
111
|
+
{display().element}
|
|
112
|
+
</span>
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
<div class={`zylem-property-value ${props.valueClass ?? ''}`}>{props.children}</div>
|
|
116
|
+
</Show>
|
|
108
117
|
}
|
|
109
118
|
>
|
|
110
119
|
<button
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Position of `t` along a track, as a 0–100 percentage clamped to the
|
|
3
|
-
* track's range. A degenerate range
|
|
3
|
+
* track's range. A degenerate range, where max is less than or equal to min,
|
|
4
|
+
* pins everything to 0.
|
|
4
5
|
*/
|
|
5
6
|
export function markerPercent(t: number, min: number, max: number): number {
|
|
6
7
|
if (!Number.isFinite(t) || max <= min) {
|
|
@@ -34,6 +34,7 @@ export function TitleBar(props: TitleBarProps) {
|
|
|
34
34
|
'closeLabel',
|
|
35
35
|
'closeTestId',
|
|
36
36
|
'dock',
|
|
37
|
+
'dockOptions',
|
|
37
38
|
'onDock',
|
|
38
39
|
'class',
|
|
39
40
|
]);
|
|
@@ -63,6 +64,7 @@ export function TitleBar(props: TitleBarProps) {
|
|
|
63
64
|
closeLabel={local.closeLabel}
|
|
64
65
|
closeTestId={local.closeTestId}
|
|
65
66
|
dock={local.dock}
|
|
67
|
+
dockOptions={local.dockOptions}
|
|
66
68
|
onDock={local.onDock}
|
|
67
69
|
/>
|
|
68
70
|
</div>
|
|
@@ -21,7 +21,7 @@ export type ArtTool =
|
|
|
21
21
|
| 'eyedropper'
|
|
22
22
|
| 'move';
|
|
23
23
|
|
|
24
|
-
type IconComponent = Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
|
|
24
|
+
export type IconComponent = Component<JSX.SvgSVGAttributes<SVGSVGElement>>;
|
|
25
25
|
|
|
26
26
|
export const ART_TOOL_ICONS: Record<ArtTool, IconComponent> = {
|
|
27
27
|
pencil: Pencil,
|
|
@@ -4,7 +4,8 @@ import { vars } from '../../theme.css';
|
|
|
4
4
|
/**
|
|
5
5
|
* Square glass icon button for toolbars — owns the `.zylem-toolbar-btn`
|
|
6
6
|
* class the editor already uses. Selected state uses the live-green jewel
|
|
7
|
-
* treatment.
|
|
7
|
+
* treatment. `data-danger` is that same selected state in the danger jewel,
|
|
8
|
+
* used by the Delete tool.
|
|
8
9
|
*/
|
|
9
10
|
globalStyle('.zylem-toolbar-btn', {
|
|
10
11
|
boxSizing: 'border-box',
|
|
@@ -67,6 +68,18 @@ globalStyle('.zylem-toolbar-btn[data-selected], .zylem-toolbar-btn.selected', {
|
|
|
67
68
|
boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowActive}`,
|
|
68
69
|
});
|
|
69
70
|
|
|
71
|
+
globalStyle('.zylem-toolbar-btn[data-danger]', {
|
|
72
|
+
borderColor: 'rgba(255, 132, 114, 0.78)',
|
|
73
|
+
background: vars.material.buttonGlassDanger,
|
|
74
|
+
boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowDanger}`,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
globalStyle('.zylem-toolbar-btn[data-danger]:hover', {
|
|
78
|
+
background: vars.material.buttonGlassDanger,
|
|
79
|
+
boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowDanger}`,
|
|
80
|
+
filter: 'brightness(1.1)',
|
|
81
|
+
});
|
|
82
|
+
|
|
70
83
|
globalStyle('.zylem-toolbar-btn[disabled]', {
|
|
71
84
|
opacity: 0.45,
|
|
72
85
|
cursor: 'not-allowed',
|
|
@@ -8,8 +8,14 @@ export interface ToolbarButtonProps {
|
|
|
8
8
|
/**
|
|
9
9
|
* The selected look alone, for a button whose label already says its state
|
|
10
10
|
* (Play / Pause). Live green. For a toggle with a constant label, use `on`.
|
|
11
|
+
* With `danger`, the same state is the danger jewel instead.
|
|
11
12
|
*/
|
|
12
13
|
selected?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Draw `selected` as the danger jewel instead of live green. The button stays
|
|
16
|
+
* ordinary glass until it is selected. Only Delete uses this.
|
|
17
|
+
*/
|
|
18
|
+
danger?: boolean;
|
|
13
19
|
/**
|
|
14
20
|
* Checkbox-like toggle. Electric blue while on, and exposed as `aria-pressed`.
|
|
15
21
|
* Pass `false` for a toggle that is off so the button still announces itself.
|
|
@@ -36,7 +42,8 @@ function insidePopup(element: Element | undefined): boolean {
|
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
44
|
* HyperGlass toolbar button: square glass icon button with a tooltip.
|
|
39
|
-
* `selected` is the live-green jewel. `
|
|
45
|
+
* `selected` is the live-green jewel. `danger` swaps that jewel for the
|
|
46
|
+
* danger one while selected. `on` is the electric-blue toggle.
|
|
40
47
|
*
|
|
41
48
|
* The tooltip portals into the layer container for its own tree, so it stays
|
|
42
49
|
* styled when the toolbar lives inside a web component's shadow root.
|
|
@@ -74,7 +81,8 @@ export function ToolbarButton(props: ToolbarButtonProps) {
|
|
|
74
81
|
? `zylem-toolbar-btn ${props.class}`
|
|
75
82
|
: 'zylem-toolbar-btn'
|
|
76
83
|
}
|
|
77
|
-
data-selected={props.selected ? '' : undefined}
|
|
84
|
+
data-selected={props.selected && !props.danger ? '' : undefined}
|
|
85
|
+
data-danger={props.selected && props.danger ? '' : undefined}
|
|
78
86
|
data-on={props.on || (props.pressed && !props.selected) ? '' : undefined}
|
|
79
87
|
>
|
|
80
88
|
{props.children}
|
|
@@ -15,6 +15,13 @@ export interface ToolbarDropdownMenuProps {
|
|
|
15
15
|
items: readonly ToolbarDropdownItem[];
|
|
16
16
|
value: string;
|
|
17
17
|
onChange: (id: string) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Draws the face as an on/off toggle instead of always selected, for a picker
|
|
20
|
+
* where one choice means off, such as the game camera among editor cameras.
|
|
21
|
+
*/
|
|
22
|
+
on?: boolean | undefined;
|
|
23
|
+
/** Splits the button: the face fires this and the chevron opens the menu. */
|
|
24
|
+
onAction?: (() => void) | undefined;
|
|
18
25
|
/** Where the menu opens. Defaults to dropping down from the face. */
|
|
19
26
|
placement?: MenuButtonProps['placement'];
|
|
20
27
|
}
|
|
@@ -33,7 +40,9 @@ export function ToolbarDropdownMenu(props: ToolbarDropdownMenuProps) {
|
|
|
33
40
|
{(item) => (
|
|
34
41
|
<MenuButton
|
|
35
42
|
label={item.label}
|
|
36
|
-
selected
|
|
43
|
+
selected={props.on === undefined}
|
|
44
|
+
on={props.on}
|
|
45
|
+
onAction={props.onAction}
|
|
37
46
|
placement={props.placement ?? 'bottom-start'}
|
|
38
47
|
menu={(close) => (
|
|
39
48
|
<div class="zylem-menu-button__stack">
|
|
@@ -5,10 +5,17 @@ import X from 'lucide-solid/icons/x';
|
|
|
5
5
|
import { Dock } from '../../icons';
|
|
6
6
|
import { DropdownMenu } from '../DropdownMenu/DropdownMenu';
|
|
7
7
|
|
|
8
|
-
/** Where a panel sits. `float` leaves it free of
|
|
9
|
-
export type DockPosition = 'top' | 'float' | 'bottom';
|
|
8
|
+
/** Where a panel sits. `float` leaves it free of a dock edge. */
|
|
9
|
+
export type DockPosition = 'left' | 'right' | 'top' | 'float' | 'bottom';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** One row in the dock menu. */
|
|
12
|
+
export interface DockOption {
|
|
13
|
+
id: DockPosition;
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Top, Float, and Bottom: the menu a strip uses when it docks to those edges. */
|
|
18
|
+
const DEFAULT_DOCK_OPTIONS: readonly DockOption[] = [
|
|
12
19
|
{ id: 'top', label: 'Top' },
|
|
13
20
|
{ id: 'float', label: 'Float' },
|
|
14
21
|
{ id: 'bottom', label: 'Bottom' },
|
|
@@ -27,6 +34,11 @@ export interface WindowControlsProps {
|
|
|
27
34
|
closeTestId?: string | undefined;
|
|
28
35
|
/** Current dock, checked in the menu. Omit for none checked. */
|
|
29
36
|
dock?: DockPosition | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Rows in the dock menu. Defaults to Top, Float, and Bottom. Pass this when
|
|
39
|
+
* the window docks to other edges, such as Left and Right.
|
|
40
|
+
*/
|
|
41
|
+
dockOptions?: readonly DockOption[] | undefined;
|
|
30
42
|
/** Renders the dock control. Called with the position chosen from the menu. */
|
|
31
43
|
onDock?: ((position: DockPosition) => void) | undefined;
|
|
32
44
|
}
|
|
@@ -34,7 +46,8 @@ export interface WindowControlsProps {
|
|
|
34
46
|
/**
|
|
35
47
|
* Shared ghost window controls, used by floating and detached panels. Close
|
|
36
48
|
* takes the danger color only while hovered or focused. Dock opens a menu of
|
|
37
|
-
* where the panel sits
|
|
49
|
+
* where the panel sits: Top, Float, and Bottom, or `dockOptions` when the
|
|
50
|
+
* window uses other edges. Only controls with a handler are rendered.
|
|
38
51
|
*/
|
|
39
52
|
export function WindowControls(props: WindowControlsProps) {
|
|
40
53
|
return (
|
|
@@ -46,7 +59,7 @@ export function WindowControls(props: WindowControlsProps) {
|
|
|
46
59
|
triggerClass="zylem-window-control zylem-window-control--dock"
|
|
47
60
|
placement="bottom-end"
|
|
48
61
|
trigger={<Dock size={11} aria-hidden="true" />}
|
|
49
|
-
items={
|
|
62
|
+
items={(props.dockOptions ?? DEFAULT_DOCK_OPTIONS).map((option) => ({
|
|
50
63
|
id: option.id,
|
|
51
64
|
label: option.label,
|
|
52
65
|
checked: props.dock === option.id,
|
package/src/components/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ export { Separator, type SeparatorProps } from './Separator/Separator';
|
|
|
70
70
|
// Composites
|
|
71
71
|
export {
|
|
72
72
|
WindowControls,
|
|
73
|
+
type DockOption,
|
|
73
74
|
type DockPosition,
|
|
74
75
|
type WindowControlsProps,
|
|
75
76
|
} from './WindowControls/WindowControls';
|
|
@@ -144,6 +145,7 @@ export {
|
|
|
144
145
|
ART_TOOL_ICONS,
|
|
145
146
|
ART_TOOL_LABELS,
|
|
146
147
|
type ArtTool,
|
|
148
|
+
type IconComponent,
|
|
147
149
|
type ToolButtonProps,
|
|
148
150
|
} from './ToolButton/ToolButton';
|
|
149
151
|
export { AddTile, type AddTileProps, type AddTileVariant } from './AddTile/AddTile';
|
|
@@ -208,6 +210,8 @@ export {
|
|
|
208
210
|
majorTickTimes,
|
|
209
211
|
formatClock,
|
|
210
212
|
formatBeats,
|
|
213
|
+
type ResolveClipDragOptions,
|
|
214
|
+
type RulerTickOptions,
|
|
211
215
|
} from './TimelineLanes/lanes-math';
|
|
212
216
|
export { TrackHeader, type TrackHeaderProps } from './TrackHeader/TrackHeader';
|
|
213
217
|
export { PianoRoll, type PianoRollProps, type PianoRollNote } from './PianoRoll/PianoRoll';
|
|
@@ -216,6 +220,7 @@ export {
|
|
|
216
220
|
isBlackKey,
|
|
217
221
|
resolveNoteDrag,
|
|
218
222
|
cellStart,
|
|
223
|
+
type NoteDragOptions,
|
|
219
224
|
} from './PianoRoll/piano-roll-math';
|
|
220
225
|
export { Knob, arcPath, type KnobProps } from './Mixer/Knob';
|
|
221
226
|
export { MixerStrip, formatDb, formatPan, type MixerStripProps } from './Mixer/MixerStrip';
|
|
@@ -231,12 +236,21 @@ export {
|
|
|
231
236
|
// Camera icons (Lucide-shaped originals; also `@zylem/ui/icons`)
|
|
232
237
|
export {
|
|
233
238
|
CAMERA_ICONS,
|
|
239
|
+
CAMERA_VIEWS,
|
|
234
240
|
FreePerspective,
|
|
235
241
|
GameCamera,
|
|
236
242
|
IsometricPerspective,
|
|
237
243
|
Perspective,
|
|
238
244
|
SideviewPerspective,
|
|
239
245
|
TopDownPerspective,
|
|
246
|
+
ViewBottom,
|
|
247
|
+
ViewIsoLeftBottom,
|
|
248
|
+
ViewIsoLeftTop,
|
|
249
|
+
ViewIsoRightBottom,
|
|
250
|
+
ViewIsoRightTop,
|
|
251
|
+
ViewLeft,
|
|
252
|
+
ViewRight,
|
|
253
|
+
ViewTop,
|
|
240
254
|
Dock,
|
|
241
255
|
cameraIconNodes,
|
|
242
256
|
createZylemIcon,
|
package/src/icons/icon-nodes.ts
CHANGED
|
@@ -73,6 +73,34 @@ export const DockNode: IconNode = [
|
|
|
73
73
|
],
|
|
74
74
|
];
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* One side-view camera — body on the left, lens pointing right, subject plane
|
|
78
|
+
* on the right — turned about the icon center. Positive degrees are clockwise,
|
|
79
|
+
* which is what puts the camera on that side of the subject:
|
|
80
|
+
* 0 left, 45 iso left/top, 90 top, 135 iso right/top, 180 right,
|
|
81
|
+
* 225 iso right/bottom, 270 bottom, 315 iso left/bottom.
|
|
82
|
+
*/
|
|
83
|
+
const FACING_RIGHT: IconNode = [
|
|
84
|
+
['rect', { x: '2', y: '8', width: '8', height: '8', rx: '2' }],
|
|
85
|
+
['path', { d: 'm10 10 4-2v8l-4-2' }],
|
|
86
|
+
['path', { d: 'M16 6h4v12h-4' }],
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
function turnCamera(angle: number): IconNode {
|
|
90
|
+
if (angle === 0) return FACING_RIGHT;
|
|
91
|
+
const transform = `rotate(${angle} 12 12)`;
|
|
92
|
+
return FACING_RIGHT.map(([element, attrs]) => [element, { ...attrs, transform }]) as IconNode;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const ViewLeftNode = turnCamera(0);
|
|
96
|
+
export const ViewIsoLeftTopNode = turnCamera(45);
|
|
97
|
+
export const ViewTopNode = turnCamera(90);
|
|
98
|
+
export const ViewIsoRightTopNode = turnCamera(135);
|
|
99
|
+
export const ViewRightNode = turnCamera(180);
|
|
100
|
+
export const ViewIsoRightBottomNode = turnCamera(225);
|
|
101
|
+
export const ViewBottomNode = turnCamera(270);
|
|
102
|
+
export const ViewIsoLeftBottomNode = turnCamera(315);
|
|
103
|
+
|
|
76
104
|
export const cameraIconNodes = {
|
|
77
105
|
perspective: PerspectiveNode,
|
|
78
106
|
'sideview-perspective': SideviewPerspectiveNode,
|
|
@@ -80,6 +108,14 @@ export const cameraIconNodes = {
|
|
|
80
108
|
'isometric-perspective': IsometricPerspectiveNode,
|
|
81
109
|
'free-perspective': FreePerspectiveNode,
|
|
82
110
|
'game-camera': GameCameraNode,
|
|
111
|
+
'view-left': ViewLeftNode,
|
|
112
|
+
'view-iso-left-top': ViewIsoLeftTopNode,
|
|
113
|
+
'view-top': ViewTopNode,
|
|
114
|
+
'view-iso-right-top': ViewIsoRightTopNode,
|
|
115
|
+
'view-right': ViewRightNode,
|
|
116
|
+
'view-iso-right-bottom': ViewIsoRightBottomNode,
|
|
117
|
+
'view-bottom': ViewBottomNode,
|
|
118
|
+
'view-iso-left-bottom': ViewIsoLeftBottomNode,
|
|
83
119
|
} as const;
|
|
84
120
|
|
|
85
121
|
export type CameraIconName = keyof typeof cameraIconNodes;
|
package/src/icons/index.ts
CHANGED
|
@@ -8,6 +8,14 @@ import {
|
|
|
8
8
|
PerspectiveNode,
|
|
9
9
|
SideviewPerspectiveNode,
|
|
10
10
|
TopDownPerspectiveNode,
|
|
11
|
+
ViewBottomNode,
|
|
12
|
+
ViewIsoLeftBottomNode,
|
|
13
|
+
ViewIsoLeftTopNode,
|
|
14
|
+
ViewIsoRightBottomNode,
|
|
15
|
+
ViewIsoRightTopNode,
|
|
16
|
+
ViewLeftNode,
|
|
17
|
+
ViewRightNode,
|
|
18
|
+
ViewTopNode,
|
|
11
19
|
} from './icon-nodes';
|
|
12
20
|
|
|
13
21
|
export type { IconNode, CameraIconName } from './icon-nodes';
|
|
@@ -19,6 +27,14 @@ export {
|
|
|
19
27
|
PerspectiveNode,
|
|
20
28
|
SideviewPerspectiveNode,
|
|
21
29
|
TopDownPerspectiveNode,
|
|
30
|
+
ViewBottomNode,
|
|
31
|
+
ViewIsoLeftBottomNode,
|
|
32
|
+
ViewIsoLeftTopNode,
|
|
33
|
+
ViewIsoRightBottomNode,
|
|
34
|
+
ViewIsoRightTopNode,
|
|
35
|
+
ViewLeftNode,
|
|
36
|
+
ViewRightNode,
|
|
37
|
+
ViewTopNode,
|
|
22
38
|
} from './icon-nodes';
|
|
23
39
|
export { createZylemIcon, type ZylemIconOptions, type ZylemIconProps } from './create-icon';
|
|
24
40
|
|
|
@@ -45,6 +61,23 @@ export const FreePerspective = createZylemIcon('FreePerspective', FreePerspectiv
|
|
|
45
61
|
/** Recorder with controller buttons (game camera). */
|
|
46
62
|
export const GameCamera = createZylemIcon('GameCamera', GameCameraNode);
|
|
47
63
|
|
|
64
|
+
/** Side camera on the left, looking right. */
|
|
65
|
+
export const ViewLeft = createZylemIcon('ViewLeft', ViewLeftNode);
|
|
66
|
+
/** Side camera on the right, looking left. */
|
|
67
|
+
export const ViewRight = createZylemIcon('ViewRight', ViewRightNode);
|
|
68
|
+
/** Camera above the subject, looking down. */
|
|
69
|
+
export const ViewTop = createZylemIcon('ViewTop', ViewTopNode);
|
|
70
|
+
/** Camera below the subject, looking up. */
|
|
71
|
+
export const ViewBottom = createZylemIcon('ViewBottom', ViewBottomNode);
|
|
72
|
+
/** Camera at the upper left, the iso left/top angle. */
|
|
73
|
+
export const ViewIsoLeftTop = createZylemIcon('ViewIsoLeftTop', ViewIsoLeftTopNode);
|
|
74
|
+
/** Camera at the upper right, the iso right/top angle. */
|
|
75
|
+
export const ViewIsoRightTop = createZylemIcon('ViewIsoRightTop', ViewIsoRightTopNode);
|
|
76
|
+
/** Camera at the lower right, the iso right/bottom angle. */
|
|
77
|
+
export const ViewIsoRightBottom = createZylemIcon('ViewIsoRightBottom', ViewIsoRightBottomNode);
|
|
78
|
+
/** Camera at the lower left, the iso left/bottom angle. */
|
|
79
|
+
export const ViewIsoLeftBottom = createZylemIcon('ViewIsoLeftBottom', ViewIsoLeftBottomNode);
|
|
80
|
+
|
|
48
81
|
/** Filled window frame. Skips the 18px toolbar metric so `size` matches a chevron. */
|
|
49
82
|
export const Dock = createZylemIcon('Dock', DockNode, {
|
|
50
83
|
className: 'zylem-dock',
|
|
@@ -60,3 +93,18 @@ export const CAMERA_ICONS = [
|
|
|
60
93
|
{ name: 'free-perspective', label: 'Free / orbit', Icon: FreePerspective },
|
|
61
94
|
{ name: 'game-camera', label: 'Game camera', Icon: GameCamera },
|
|
62
95
|
] as const;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The eight angles around the view cube, clockwise from the top-left corner.
|
|
99
|
+
* Free, game, and perspective already live on `CAMERA_ICONS`.
|
|
100
|
+
*/
|
|
101
|
+
export const CAMERA_VIEWS = [
|
|
102
|
+
{ name: 'view-iso-left-top', label: 'Iso left/top', Icon: ViewIsoLeftTop },
|
|
103
|
+
{ name: 'view-top', label: 'Top', Icon: ViewTop },
|
|
104
|
+
{ name: 'view-iso-right-top', label: 'Iso right/top', Icon: ViewIsoRightTop },
|
|
105
|
+
{ name: 'view-right', label: 'Right', Icon: ViewRight },
|
|
106
|
+
{ name: 'view-iso-right-bottom', label: 'Iso right/bottom', Icon: ViewIsoRightBottom },
|
|
107
|
+
{ name: 'view-bottom', label: 'Bottom', Icon: ViewBottom },
|
|
108
|
+
{ name: 'view-iso-left-bottom', label: 'Iso left/bottom', Icon: ViewIsoLeftBottom },
|
|
109
|
+
{ name: 'view-left', label: 'Left', Icon: ViewLeft },
|
|
110
|
+
] as const;
|