@zylem/ui 0.8.8 → 0.8.11
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/chunk-V47CGFWX.js +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.js +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.js +1 -1
- package/package.json +22 -11
- package/src/components/Accordion/Accordion.tsx +10 -1
- package/src/components/AddTile/AddTile.css.ts +95 -0
- package/src/components/AddTile/AddTile.tsx +45 -0
- package/src/components/BuildLabel/BuildLabel.css.ts +31 -0
- package/src/components/BuildLabel/BuildLabel.tsx +37 -0
- package/src/components/BuildLabel/mount-build-label.ts +77 -0
- package/src/components/BuildLabel/resolve-build-version.ts +47 -0
- package/src/components/Callout/Callout.css.ts +45 -0
- package/src/components/Callout/Callout.tsx +32 -0
- package/src/components/Card/Card.css.ts +18 -0
- package/src/components/Card/Card.tsx +25 -2
- package/src/components/ColorPalette/ColorPalette.css.ts +51 -0
- package/src/components/ColorPalette/ColorPalette.tsx +78 -0
- package/src/components/Dialog/Dialog.css.ts +69 -2
- package/src/components/Dialog/Dialog.tsx +35 -7
- package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
- package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
- package/src/components/EmptyState/EmptyState.css.ts +9 -0
- package/src/components/EmptyState/EmptyState.tsx +3 -1
- package/src/components/FrameStrip/FrameStrip.css.ts +138 -0
- package/src/components/FrameStrip/FrameStrip.tsx +146 -0
- package/src/components/Menubar/Menubar.css.ts +52 -0
- package/src/components/Menubar/Menubar.tsx +77 -0
- package/src/components/Mixer/Knob.tsx +188 -0
- package/src/components/Mixer/Mixer.css.ts +246 -0
- package/src/components/Mixer/MixerStrip.tsx +150 -0
- package/src/components/NumberField/NumberField.tsx +5 -1
- package/src/components/Panel/Panel.tsx +6 -5
- package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
- package/src/components/PianoRoll/PianoRoll.tsx +353 -0
- package/src/components/PianoRoll/piano-roll-math.ts +89 -0
- package/src/components/PixelGrid/PixelGrid.css.ts +30 -0
- package/src/components/PixelGrid/PixelGrid.tsx +48 -0
- package/src/components/PlaybackControls/PlaybackControls.css.ts +53 -0
- package/src/components/PlaybackControls/PlaybackControls.tsx +80 -0
- package/src/components/Property/Property.css.ts +1 -1
- package/src/components/Property/Property.tsx +3 -0
- package/src/components/Sidebar/Sidebar.css.ts +8 -0
- package/src/components/Sidebar/Sidebar.tsx +10 -2
- package/src/components/Slider/Slider.tsx +6 -0
- package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +103 -0
- package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +172 -0
- package/src/components/SpriteSizePicker/snap-size.ts +28 -0
- package/src/components/Tabs/Tabs.css.ts +48 -0
- package/src/components/Tabs/Tabs.tsx +41 -3
- package/src/components/TextField/TextField.tsx +49 -5
- package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
- package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
- package/src/components/TimelineLanes/lanes-math.ts +182 -0
- package/src/components/TimelineTrack/TimelineTrack.css.ts +163 -0
- package/src/components/TimelineTrack/TimelineTrack.tsx +90 -0
- package/src/components/TimelineTrack/timeline-math.ts +43 -0
- package/src/components/Toast/Toast.css.ts +48 -0
- package/src/components/Toast/Toast.tsx +37 -0
- package/src/components/ToolButton/ToolButton.tsx +92 -0
- package/src/components/ToolGroup/ToolGroup.css.ts +35 -0
- package/src/components/ToolGroup/ToolGroup.tsx +29 -0
- package/src/components/ToolboxButton/ToolboxButton.css.ts +131 -0
- package/src/components/ToolboxButton/ToolboxButton.tsx +88 -0
- package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
- package/src/components/TrackHeader/TrackHeader.tsx +145 -0
- package/src/components/TransportBar/TransportBar.css.ts +65 -0
- package/src/components/TransportBar/TransportBar.tsx +154 -0
- package/src/components/Waveform/Waveform.css.ts +77 -0
- package/src/components/Waveform/Waveform.tsx +195 -0
- package/src/components/Waveform/peaks.ts +49 -0
- package/src/components/index.ts +122 -0
- package/src/global/accordion.css.ts +23 -0
- package/src/global/hyperglass-base.css.ts +9 -6
- package/src/styles.ts +23 -0
- package/src/theme.css.ts +15 -0
- package/vite/collect-zylem-versions.ts +82 -0
- package/vite/index.ts +30 -0
- package/dist/chunk-3TP2SNNW.js +0 -1
|
@@ -19,6 +19,14 @@ globalStyle('.sidebar', {
|
|
|
19
19
|
},
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
globalStyle('.sidebar--borderless', {
|
|
23
|
+
height: 'auto',
|
|
24
|
+
overflow: 'visible',
|
|
25
|
+
background: 'none',
|
|
26
|
+
borderRight: 'none',
|
|
27
|
+
backdropFilter: 'none',
|
|
28
|
+
});
|
|
29
|
+
|
|
22
30
|
globalStyle('.sidebar-section', {
|
|
23
31
|
display: 'flex',
|
|
24
32
|
flexDirection: 'column',
|
|
@@ -3,6 +3,12 @@ import type { JSX } from 'solid-js';
|
|
|
3
3
|
|
|
4
4
|
export interface SidebarProps extends JSX.HTMLAttributes<HTMLElement> {
|
|
5
5
|
children?: JSX.Element;
|
|
6
|
+
/**
|
|
7
|
+
* Drop the glass surface and trailing border so the list sits inside a
|
|
8
|
+
* panel that already draws them (an inspector rail, a dialog column).
|
|
9
|
+
* It also stops filling its container, so it stacks with siblings.
|
|
10
|
+
*/
|
|
11
|
+
borderless?: boolean;
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
/**
|
|
@@ -10,10 +16,12 @@ export interface SidebarProps extends JSX.HTMLAttributes<HTMLElement> {
|
|
|
10
16
|
* and `SidebarItem`.
|
|
11
17
|
*/
|
|
12
18
|
export function Sidebar(props: SidebarProps) {
|
|
13
|
-
const [local, rest] = splitProps(props, ['children', 'class']);
|
|
19
|
+
const [local, rest] = splitProps(props, ['children', 'class', 'borderless']);
|
|
14
20
|
return (
|
|
15
21
|
<aside
|
|
16
|
-
class={local.
|
|
22
|
+
class={['sidebar', 'zylem-scroll', local.borderless ? 'sidebar--borderless' : '', local.class ?? '']
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join(' ')}
|
|
17
25
|
{...rest}
|
|
18
26
|
>
|
|
19
27
|
{local.children}
|
|
@@ -5,6 +5,11 @@ export interface SliderProps {
|
|
|
5
5
|
label?: string;
|
|
6
6
|
value?: number;
|
|
7
7
|
onChange?: (value: number) => void;
|
|
8
|
+
/**
|
|
9
|
+
* Fires once when a drag or key press finishes, with the final value. Lets
|
|
10
|
+
* an editor record one undo step per gesture while `onChange` previews.
|
|
11
|
+
*/
|
|
12
|
+
onCommit?: (value: number) => void;
|
|
8
13
|
minValue?: number;
|
|
9
14
|
maxValue?: number;
|
|
10
15
|
step?: number;
|
|
@@ -23,6 +28,7 @@ export function Slider(props: SliderProps) {
|
|
|
23
28
|
class={props.class ? `zylem-slider-root ${props.class}` : 'zylem-slider-root'}
|
|
24
29
|
value={props.value !== undefined ? [props.value] : undefined}
|
|
25
30
|
onChange={(values) => props.onChange?.(values[0])}
|
|
31
|
+
onChangeEnd={(values) => props.onCommit?.(values[0])}
|
|
26
32
|
minValue={props.minValue}
|
|
27
33
|
maxValue={props.maxValue}
|
|
28
34
|
step={props.step}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { globalStyle } from '@vanilla-extract/css';
|
|
2
|
+
import { vars } from '../../theme.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sprite size picker: preset row, a 1:1 stage showing the real pixel
|
|
6
|
+
* footprint with edge/corner drag handles, and the optional W/H fields.
|
|
7
|
+
*/
|
|
8
|
+
globalStyle('.zylem-size-picker', {
|
|
9
|
+
display: 'flex',
|
|
10
|
+
flexDirection: 'column',
|
|
11
|
+
gap: vars.spacing.md,
|
|
12
|
+
fontFamily: vars.typography.fontFamily,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
globalStyle('.zylem-size-picker-presets', {
|
|
16
|
+
display: 'flex',
|
|
17
|
+
flexWrap: 'wrap',
|
|
18
|
+
gap: vars.spacing.sm,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
globalStyle('.zylem-size-picker-presets .zylem-button', {
|
|
22
|
+
minWidth: '40px',
|
|
23
|
+
fontVariantNumeric: 'tabular-nums',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
globalStyle('.zylem-size-picker-stage', {
|
|
27
|
+
boxSizing: 'border-box',
|
|
28
|
+
position: 'relative',
|
|
29
|
+
flex: 'none',
|
|
30
|
+
padding: '8px',
|
|
31
|
+
borderRadius: vars.radii.control,
|
|
32
|
+
border: '1px solid rgba(97, 166, 232, 0.28)',
|
|
33
|
+
background: vars.material.fieldGlass,
|
|
34
|
+
boxShadow: vars.effects.shadowInset,
|
|
35
|
+
overflow: 'hidden',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Sized to the sprite at 1:1; the grid's own 1px border sits just outside.
|
|
39
|
+
globalStyle('.zylem-size-picker-frame', {
|
|
40
|
+
position: 'relative',
|
|
41
|
+
boxSizing: 'content-box',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Handles: slim glass bars along the right and bottom edges plus a corner
|
|
45
|
+
// jewel. Hit areas straddle the edge so small sprites are still grabbable.
|
|
46
|
+
globalStyle('.zylem-size-picker-handle', {
|
|
47
|
+
position: 'absolute',
|
|
48
|
+
boxSizing: 'border-box',
|
|
49
|
+
borderRadius: '999px',
|
|
50
|
+
border: '1px solid rgba(178, 219, 255, 0.85)',
|
|
51
|
+
background:
|
|
52
|
+
'linear-gradient(180deg, rgba(240, 250, 255, 0.96), rgba(140, 190, 235, 0.92))',
|
|
53
|
+
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.75), 0 1px 4px rgba(0,0,0,0.5), ${vars.effects.glowPrimary}`,
|
|
54
|
+
touchAction: 'none',
|
|
55
|
+
transition: `box-shadow ${vars.motion.fast} ${vars.motion.easeOut}, transform ${vars.motion.fast} ${vars.motion.easeOut}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
globalStyle('.zylem-size-picker-handle:hover, .zylem-size-picker-handle[data-dragging]', {
|
|
59
|
+
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.75), 0 1px 4px rgba(0,0,0,0.5), ${vars.effects.focusRing}`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
globalStyle('.zylem-size-picker-handle[data-edge="right"]', {
|
|
63
|
+
top: '50%',
|
|
64
|
+
right: '-6px',
|
|
65
|
+
width: '8px',
|
|
66
|
+
height: 'min(28px, 60%)',
|
|
67
|
+
transform: 'translateY(-50%)',
|
|
68
|
+
cursor: 'ew-resize',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
globalStyle('.zylem-size-picker-handle[data-edge="bottom"]', {
|
|
72
|
+
left: '50%',
|
|
73
|
+
bottom: '-6px',
|
|
74
|
+
width: 'min(28px, 60%)',
|
|
75
|
+
height: '8px',
|
|
76
|
+
transform: 'translateX(-50%)',
|
|
77
|
+
cursor: 'ns-resize',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
globalStyle('.zylem-size-picker-handle[data-edge="corner"]', {
|
|
81
|
+
right: '-7px',
|
|
82
|
+
bottom: '-7px',
|
|
83
|
+
width: '14px',
|
|
84
|
+
height: '14px',
|
|
85
|
+
cursor: 'nwse-resize',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
globalStyle('.zylem-size-picker-readout', {
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
right: vars.spacing.sm,
|
|
91
|
+
bottom: vars.spacing.xs,
|
|
92
|
+
fontSize: `calc(${vars.typography.fontSize} - 2px)`,
|
|
93
|
+
fontVariantNumeric: 'tabular-nums',
|
|
94
|
+
color: vars.colors.primary,
|
|
95
|
+
textShadow: '0 1px 1px rgba(0,0,0,0.6)',
|
|
96
|
+
pointerEvents: 'none',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
globalStyle('.zylem-size-picker-fields', {
|
|
100
|
+
display: 'grid',
|
|
101
|
+
gridTemplateColumns: '1fr 1fr',
|
|
102
|
+
gap: vars.spacing.md,
|
|
103
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { For, Show, onCleanup } from 'solid-js';
|
|
2
|
+
import { Button } from '../Button/Button';
|
|
3
|
+
import { NumberField } from '../NumberField/NumberField';
|
|
4
|
+
import { PixelGrid } from '../PixelGrid/PixelGrid';
|
|
5
|
+
import { SPRITE_SIZE_PRESETS, type SpriteSize, snapSize } from './snap-size';
|
|
6
|
+
|
|
7
|
+
export interface SpriteSizePickerProps {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
onChange: (size: SpriteSize) => void;
|
|
11
|
+
/** Square presets; each sets both dimensions. */
|
|
12
|
+
presets?: readonly number[];
|
|
13
|
+
/** Smallest dimension reachable by dragging or typing. */
|
|
14
|
+
min?: number;
|
|
15
|
+
/** Largest dimension; also sets the size of the 1:1 preview stage unless `stageSize` is given. */
|
|
16
|
+
max?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Side of the square preview stage in pixels. Defaults to `max`, which is
|
|
19
|
+
* right when the limit is small; a picker allowing 1024px sprites sets this
|
|
20
|
+
* so the stage still fits a dialog. Sprites larger than the stage are shown
|
|
21
|
+
* cropped at 1:1 with the readout carrying the true size.
|
|
22
|
+
*/
|
|
23
|
+
stageSize?: number;
|
|
24
|
+
/** Drag increment in source pixels. */
|
|
25
|
+
step?: number;
|
|
26
|
+
/** Render Width / Height number fields under the preview. */
|
|
27
|
+
showFields?: boolean;
|
|
28
|
+
class?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Edge = 'right' | 'bottom' | 'corner';
|
|
32
|
+
|
|
33
|
+
/** Extra room around the stage so the largest size still shows its handles. */
|
|
34
|
+
const STAGE_GUTTER = 24;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Sprite canvas size chooser: power-of-two presets, a 1:1 preview of the
|
|
38
|
+
* actual pixel footprint with drag handles that grow it in `step` increments,
|
|
39
|
+
* and optional numeric fields for exact values.
|
|
40
|
+
*/
|
|
41
|
+
export function SpriteSizePicker(props: SpriteSizePickerProps) {
|
|
42
|
+
const min = () => props.min ?? 2;
|
|
43
|
+
const max = () => props.max ?? 256;
|
|
44
|
+
const step = () => props.step ?? 2;
|
|
45
|
+
const presets = () => props.presets ?? SPRITE_SIZE_PRESETS;
|
|
46
|
+
const snap = (value: number) => snapSize(value, { min: min(), max: max(), step: step() });
|
|
47
|
+
|
|
48
|
+
const emit = (width: number, height: number) => {
|
|
49
|
+
if (width !== props.width || height !== props.height) {
|
|
50
|
+
props.onChange({ width, height });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let release: (() => void) | undefined;
|
|
55
|
+
onCleanup(() => release?.());
|
|
56
|
+
|
|
57
|
+
const startDrag = (edge: Edge) => (event: PointerEvent) => {
|
|
58
|
+
if (event.button !== 0) return;
|
|
59
|
+
const handle = event.currentTarget as HTMLElement;
|
|
60
|
+
const originX = event.clientX;
|
|
61
|
+
const originY = event.clientY;
|
|
62
|
+
const startWidth = props.width;
|
|
63
|
+
const startHeight = props.height;
|
|
64
|
+
// Capture keeps the drag alive when the pointer outruns the handle; a
|
|
65
|
+
// synthetic event has no active pointer to capture, so tolerate that.
|
|
66
|
+
try {
|
|
67
|
+
handle.setPointerCapture(event.pointerId);
|
|
68
|
+
} catch {
|
|
69
|
+
/* no active pointer */
|
|
70
|
+
}
|
|
71
|
+
handle.dataset.dragging = '';
|
|
72
|
+
|
|
73
|
+
const onMove = (move: PointerEvent) => {
|
|
74
|
+
const dx = move.clientX - originX;
|
|
75
|
+
const dy = move.clientY - originY;
|
|
76
|
+
const width = edge === 'bottom' ? props.width : snap(startWidth + dx);
|
|
77
|
+
const height = edge === 'right' ? props.height : snap(startHeight + dy);
|
|
78
|
+
emit(width, height);
|
|
79
|
+
};
|
|
80
|
+
const onUp = () => release?.();
|
|
81
|
+
release = () => {
|
|
82
|
+
handle.removeEventListener('pointermove', onMove);
|
|
83
|
+
handle.removeEventListener('pointerup', onUp);
|
|
84
|
+
handle.removeEventListener('pointercancel', onUp);
|
|
85
|
+
delete handle.dataset.dragging;
|
|
86
|
+
if (handle.hasPointerCapture(event.pointerId)) {
|
|
87
|
+
handle.releasePointerCapture(event.pointerId);
|
|
88
|
+
}
|
|
89
|
+
release = undefined;
|
|
90
|
+
};
|
|
91
|
+
handle.addEventListener('pointermove', onMove);
|
|
92
|
+
handle.addEventListener('pointerup', onUp);
|
|
93
|
+
handle.addEventListener('pointercancel', onUp);
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const stage = () => props.stageSize ?? max();
|
|
98
|
+
const stageSize = () => `${stage() + STAGE_GUTTER}px`;
|
|
99
|
+
// The frame is the sprite at 1:1, cropped to the stage when it outgrows it.
|
|
100
|
+
const frameWidth = () => Math.min(props.width, stage());
|
|
101
|
+
const frameHeight = () => Math.min(props.height, stage());
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div class={props.class ? `zylem-size-picker ${props.class}` : 'zylem-size-picker'}>
|
|
105
|
+
<div class="zylem-size-picker-presets" role="group" aria-label="Size presets">
|
|
106
|
+
<For each={presets()}>
|
|
107
|
+
{(preset) => (
|
|
108
|
+
<Button
|
|
109
|
+
size="sm"
|
|
110
|
+
variant={props.width === preset && props.height === preset ? 'primary' : 'default'}
|
|
111
|
+
aria-pressed={props.width === preset && props.height === preset}
|
|
112
|
+
onClick={() => emit(preset, preset)}
|
|
113
|
+
>
|
|
114
|
+
{preset}
|
|
115
|
+
</Button>
|
|
116
|
+
)}
|
|
117
|
+
</For>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div class="zylem-size-picker-stage" style={{ width: stageSize(), height: stageSize() }}>
|
|
121
|
+
<div
|
|
122
|
+
class="zylem-size-picker-frame"
|
|
123
|
+
style={{ width: `${frameWidth()}px`, height: `${frameHeight()}px` }}
|
|
124
|
+
>
|
|
125
|
+
<PixelGrid width={frameWidth()} height={frameHeight()} checkerCell={4} />
|
|
126
|
+
<div
|
|
127
|
+
class="zylem-size-picker-handle"
|
|
128
|
+
data-edge="right"
|
|
129
|
+
aria-hidden="true"
|
|
130
|
+
onPointerDown={startDrag('right')}
|
|
131
|
+
/>
|
|
132
|
+
<div
|
|
133
|
+
class="zylem-size-picker-handle"
|
|
134
|
+
data-edge="bottom"
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
onPointerDown={startDrag('bottom')}
|
|
137
|
+
/>
|
|
138
|
+
<div
|
|
139
|
+
class="zylem-size-picker-handle"
|
|
140
|
+
data-edge="corner"
|
|
141
|
+
aria-hidden="true"
|
|
142
|
+
onPointerDown={startDrag('corner')}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
<output class="zylem-size-picker-readout" aria-live="polite">
|
|
146
|
+
{props.width} × {props.height} px
|
|
147
|
+
</output>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<Show when={props.showFields ?? true}>
|
|
151
|
+
<div class="zylem-size-picker-fields">
|
|
152
|
+
<NumberField
|
|
153
|
+
label="Width"
|
|
154
|
+
value={props.width}
|
|
155
|
+
minValue={min()}
|
|
156
|
+
maxValue={max()}
|
|
157
|
+
step={step()}
|
|
158
|
+
onChange={(width) => emit(snap(width), props.height)}
|
|
159
|
+
/>
|
|
160
|
+
<NumberField
|
|
161
|
+
label="Height"
|
|
162
|
+
value={props.height}
|
|
163
|
+
minValue={min()}
|
|
164
|
+
maxValue={max()}
|
|
165
|
+
step={step()}
|
|
166
|
+
onChange={(height) => emit(props.width, snap(height))}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
</Show>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SnapOptions {
|
|
2
|
+
min: number;
|
|
3
|
+
max: number;
|
|
4
|
+
step: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Sprite dimensions in source pixels. */
|
|
8
|
+
export interface SpriteSize {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Powers of two from 8 through 256; the default preset row. */
|
|
14
|
+
export const SPRITE_SIZE_PRESETS: readonly number[] = [8, 16, 32, 64, 128, 256];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Round a dragged dimension to the nearest step and keep it inside the
|
|
18
|
+
* allowed range. Non-finite input (a NaN from an empty field) lands on `min`.
|
|
19
|
+
*/
|
|
20
|
+
export function snapSize(value: number, options: SnapOptions): number {
|
|
21
|
+
const { min, max, step } = options;
|
|
22
|
+
if (!Number.isFinite(value)) {
|
|
23
|
+
return min;
|
|
24
|
+
}
|
|
25
|
+
const unit = step > 0 ? step : 1;
|
|
26
|
+
const snapped = Math.round(value / unit) * unit;
|
|
27
|
+
return Math.min(max, Math.max(min, snapped));
|
|
28
|
+
}
|
|
@@ -59,6 +59,54 @@ globalStyle('.zylem-tabs-trigger[data-disabled]', {
|
|
|
59
59
|
cursor: 'not-allowed',
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
globalStyle('.zylem-tabs-trigger-label', {
|
|
63
|
+
minWidth: 0,
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
textOverflow: 'ellipsis',
|
|
66
|
+
whiteSpace: 'nowrap',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Unsaved marker: a small live dot.
|
|
70
|
+
globalStyle('.zylem-tabs-dirty', {
|
|
71
|
+
flex: 'none',
|
|
72
|
+
width: '6px',
|
|
73
|
+
height: '6px',
|
|
74
|
+
borderRadius: '50%',
|
|
75
|
+
background: vars.colors.active,
|
|
76
|
+
boxShadow: vars.effects.glowActive,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Close affordance: flat at rest, accent on hover (like the window close).
|
|
80
|
+
globalStyle('.zylem-tabs-close', {
|
|
81
|
+
display: 'inline-flex',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
flex: 'none',
|
|
85
|
+
width: '16px',
|
|
86
|
+
height: '16px',
|
|
87
|
+
marginRight: `calc(${vars.spacing.xs} * -1)`,
|
|
88
|
+
borderRadius: '4px',
|
|
89
|
+
color: vars.colors.textSecondary,
|
|
90
|
+
cursor: 'pointer',
|
|
91
|
+
transition: `background ${vars.motion.fast} ${vars.motion.easeOut}, color ${vars.motion.fast} ${vars.motion.easeOut}`,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
globalStyle('.zylem-tabs-close svg', {
|
|
95
|
+
width: '11px',
|
|
96
|
+
height: '11px',
|
|
97
|
+
stroke: 'currentColor',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
globalStyle('.zylem-tabs-close:hover', {
|
|
101
|
+
background: vars.colors.accent,
|
|
102
|
+
color: '#FFF4F2',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
globalStyle('.zylem-tabs-close:focus-visible', {
|
|
106
|
+
outline: 'none',
|
|
107
|
+
boxShadow: vars.effects.focusRing,
|
|
108
|
+
});
|
|
109
|
+
|
|
62
110
|
globalStyle('.zylem-tabs-content', {
|
|
63
111
|
flex: 1,
|
|
64
112
|
minHeight: 0,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Tabs as KTabs } from '@kobalte/core';
|
|
2
|
+
import X from 'lucide-solid/icons/x';
|
|
3
|
+
import { Show } from 'solid-js';
|
|
2
4
|
import type { JSX } from 'solid-js';
|
|
3
5
|
|
|
4
6
|
export interface TabsProps {
|
|
@@ -38,12 +40,29 @@ export function TabsList(props: { children: JSX.Element; class?: string }) {
|
|
|
38
40
|
);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
export
|
|
43
|
+
export interface TabsTriggerProps {
|
|
42
44
|
value: string;
|
|
43
45
|
children: JSX.Element;
|
|
44
46
|
disabled?: boolean;
|
|
45
47
|
class?: string;
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Document-style tabs: shows a close affordance after the label. It is a
|
|
50
|
+
* `role="button"` span (a button may not nest in the trigger button) and
|
|
51
|
+
* closing does not select the tab.
|
|
52
|
+
*/
|
|
53
|
+
onClose?: () => void;
|
|
54
|
+
/** Accessible name of the close affordance; defaults to `Close tab`. */
|
|
55
|
+
closeLabel?: string;
|
|
56
|
+
/** Unsaved-changes marker before the close affordance. */
|
|
57
|
+
dirty?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TabsTrigger(props: TabsTriggerProps) {
|
|
61
|
+
const close = (event: Event) => {
|
|
62
|
+
event.stopPropagation();
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
props.onClose?.();
|
|
65
|
+
};
|
|
47
66
|
return (
|
|
48
67
|
<KTabs.Trigger
|
|
49
68
|
class={
|
|
@@ -52,7 +71,26 @@ export function TabsTrigger(props: {
|
|
|
52
71
|
value={props.value}
|
|
53
72
|
disabled={props.disabled}
|
|
54
73
|
>
|
|
55
|
-
{props.children}
|
|
74
|
+
<span class="zylem-tabs-trigger-label">{props.children}</span>
|
|
75
|
+
<Show when={props.dirty}>
|
|
76
|
+
<span class="zylem-tabs-dirty" aria-label="Unsaved changes" title="Unsaved changes" />
|
|
77
|
+
</Show>
|
|
78
|
+
<Show when={props.onClose}>
|
|
79
|
+
<span
|
|
80
|
+
class="zylem-tabs-close"
|
|
81
|
+
role="button"
|
|
82
|
+
tabIndex={0}
|
|
83
|
+
aria-label={props.closeLabel ?? 'Close tab'}
|
|
84
|
+
title={props.closeLabel ?? 'Close tab'}
|
|
85
|
+
onClick={close}
|
|
86
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
87
|
+
onKeyDown={(event) => {
|
|
88
|
+
if (event.key === 'Enter' || event.key === ' ') close(event);
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<X aria-hidden="true" />
|
|
92
|
+
</span>
|
|
93
|
+
</Show>
|
|
56
94
|
</KTabs.Trigger>
|
|
57
95
|
);
|
|
58
96
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TextField as KTextField } from '@kobalte/core';
|
|
2
|
-
import { Show, splitProps } from 'solid-js';
|
|
2
|
+
import { createEffect, createSignal, on, Show, splitProps } from 'solid-js';
|
|
3
3
|
import type { JSX } from 'solid-js';
|
|
4
4
|
|
|
5
5
|
export interface TextFieldProps {
|
|
@@ -7,8 +7,17 @@ export interface TextFieldProps {
|
|
|
7
7
|
error?: string;
|
|
8
8
|
placeholder?: string;
|
|
9
9
|
value?: string;
|
|
10
|
+
/** Fires on every edit. */
|
|
10
11
|
onChange?: (value: string) => void;
|
|
12
|
+
/**
|
|
13
|
+
* Fires on blur and, for single-line fields, on Enter. While set, the field
|
|
14
|
+
* edits a local draft and only reports it here, so a parent that normalises
|
|
15
|
+
* or rejects values (rename, comma lists, keys) does not fight the typing;
|
|
16
|
+
* the draft snaps back to `value` after each commit.
|
|
17
|
+
*/
|
|
18
|
+
onCommit?: (value: string) => void;
|
|
11
19
|
onInput?: JSX.EventHandlerUnion<HTMLInputElement | HTMLTextAreaElement, InputEvent>;
|
|
20
|
+
onBlur?: JSX.EventHandlerUnion<HTMLInputElement | HTMLTextAreaElement, FocusEvent>;
|
|
12
21
|
type?: string;
|
|
13
22
|
name?: string;
|
|
14
23
|
disabled?: boolean;
|
|
@@ -21,18 +30,51 @@ export interface TextFieldProps {
|
|
|
21
30
|
onKeyDown?: JSX.EventHandlerUnion<HTMLInputElement | HTMLTextAreaElement, KeyboardEvent>;
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
function callHandler<T, E extends Event>(
|
|
34
|
+
handler: JSX.EventHandlerUnion<T, E> | undefined,
|
|
35
|
+
event: E & { currentTarget: T; target: Element },
|
|
36
|
+
) {
|
|
37
|
+
if (!handler) return;
|
|
38
|
+
if (typeof handler === 'function') handler(event);
|
|
39
|
+
else handler[0](handler[1], event);
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
/**
|
|
25
43
|
* HyperGlass text field: inset glass input with an uppercase HyperCard-style
|
|
26
44
|
* label. Set `multiline` for a textarea.
|
|
27
45
|
*/
|
|
28
46
|
export function TextField(props: TextFieldProps) {
|
|
29
47
|
const [local] = splitProps(props, ['label', 'error', 'multiline', 'class']);
|
|
48
|
+
const [draft, setDraft] = createSignal(props.value ?? '');
|
|
49
|
+
createEffect(on(() => props.value, (value) => setDraft(value ?? '')));
|
|
50
|
+
|
|
51
|
+
const value = () => (props.onCommit ? draft() : props.value);
|
|
52
|
+
const change = (next: string) => {
|
|
53
|
+
setDraft(next);
|
|
54
|
+
props.onChange?.(next);
|
|
55
|
+
};
|
|
56
|
+
const commit = () => {
|
|
57
|
+
if (!props.onCommit) return;
|
|
58
|
+
props.onCommit(draft());
|
|
59
|
+
setDraft(props.value ?? '');
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const blur = (event: FocusEvent & { currentTarget: HTMLInputElement | HTMLTextAreaElement; target: Element }) => {
|
|
63
|
+
callHandler(props.onBlur, event);
|
|
64
|
+
commit();
|
|
65
|
+
};
|
|
66
|
+
const keyDown = (
|
|
67
|
+
event: KeyboardEvent & { currentTarget: HTMLInputElement | HTMLTextAreaElement; target: Element },
|
|
68
|
+
) => {
|
|
69
|
+
callHandler(props.onKeyDown, event);
|
|
70
|
+
if (event.key === 'Enter' && !local.multiline && !event.defaultPrevented) commit();
|
|
71
|
+
};
|
|
30
72
|
|
|
31
73
|
return (
|
|
32
74
|
<KTextField.Root
|
|
33
75
|
class={local.class ? `zylem-field-root ${local.class}` : 'zylem-field-root'}
|
|
34
|
-
value={
|
|
35
|
-
onChange={
|
|
76
|
+
value={value()}
|
|
77
|
+
onChange={change}
|
|
36
78
|
name={props.name}
|
|
37
79
|
disabled={props.disabled}
|
|
38
80
|
readOnly={props.readOnly}
|
|
@@ -53,7 +95,8 @@ export function TextField(props: TextFieldProps) {
|
|
|
53
95
|
placeholder={props.placeholder}
|
|
54
96
|
autofocus={props.autofocus}
|
|
55
97
|
onInput={props.onInput as JSX.EventHandlerUnion<HTMLInputElement, InputEvent>}
|
|
56
|
-
|
|
98
|
+
onBlur={blur}
|
|
99
|
+
onKeyDown={keyDown}
|
|
57
100
|
/>
|
|
58
101
|
}
|
|
59
102
|
>
|
|
@@ -63,7 +106,8 @@ export function TextField(props: TextFieldProps) {
|
|
|
63
106
|
rows={props.rows}
|
|
64
107
|
autofocus={props.autofocus}
|
|
65
108
|
onInput={props.onInput as JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>}
|
|
66
|
-
|
|
109
|
+
onBlur={blur}
|
|
110
|
+
onKeyDown={keyDown}
|
|
67
111
|
/>
|
|
68
112
|
</Show>
|
|
69
113
|
<Show when={local.error}>
|