@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.
Files changed (81) hide show
  1. package/dist/chunk-V47CGFWX.js +1 -0
  2. package/dist/index.css +1 -1
  3. package/dist/index.d.ts +15 -0
  4. package/dist/index.js +1 -1
  5. package/dist/styles.css +1 -1
  6. package/dist/styles.js +1 -1
  7. package/package.json +22 -11
  8. package/src/components/Accordion/Accordion.tsx +10 -1
  9. package/src/components/AddTile/AddTile.css.ts +95 -0
  10. package/src/components/AddTile/AddTile.tsx +45 -0
  11. package/src/components/BuildLabel/BuildLabel.css.ts +31 -0
  12. package/src/components/BuildLabel/BuildLabel.tsx +37 -0
  13. package/src/components/BuildLabel/mount-build-label.ts +77 -0
  14. package/src/components/BuildLabel/resolve-build-version.ts +47 -0
  15. package/src/components/Callout/Callout.css.ts +45 -0
  16. package/src/components/Callout/Callout.tsx +32 -0
  17. package/src/components/Card/Card.css.ts +18 -0
  18. package/src/components/Card/Card.tsx +25 -2
  19. package/src/components/ColorPalette/ColorPalette.css.ts +51 -0
  20. package/src/components/ColorPalette/ColorPalette.tsx +78 -0
  21. package/src/components/Dialog/Dialog.css.ts +69 -2
  22. package/src/components/Dialog/Dialog.tsx +35 -7
  23. package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
  24. package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
  25. package/src/components/EmptyState/EmptyState.css.ts +9 -0
  26. package/src/components/EmptyState/EmptyState.tsx +3 -1
  27. package/src/components/FrameStrip/FrameStrip.css.ts +138 -0
  28. package/src/components/FrameStrip/FrameStrip.tsx +146 -0
  29. package/src/components/Menubar/Menubar.css.ts +52 -0
  30. package/src/components/Menubar/Menubar.tsx +77 -0
  31. package/src/components/Mixer/Knob.tsx +188 -0
  32. package/src/components/Mixer/Mixer.css.ts +246 -0
  33. package/src/components/Mixer/MixerStrip.tsx +150 -0
  34. package/src/components/NumberField/NumberField.tsx +5 -1
  35. package/src/components/Panel/Panel.tsx +6 -5
  36. package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
  37. package/src/components/PianoRoll/PianoRoll.tsx +353 -0
  38. package/src/components/PianoRoll/piano-roll-math.ts +89 -0
  39. package/src/components/PixelGrid/PixelGrid.css.ts +30 -0
  40. package/src/components/PixelGrid/PixelGrid.tsx +48 -0
  41. package/src/components/PlaybackControls/PlaybackControls.css.ts +53 -0
  42. package/src/components/PlaybackControls/PlaybackControls.tsx +80 -0
  43. package/src/components/Property/Property.css.ts +1 -1
  44. package/src/components/Property/Property.tsx +3 -0
  45. package/src/components/Sidebar/Sidebar.css.ts +8 -0
  46. package/src/components/Sidebar/Sidebar.tsx +10 -2
  47. package/src/components/Slider/Slider.tsx +6 -0
  48. package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +103 -0
  49. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +172 -0
  50. package/src/components/SpriteSizePicker/snap-size.ts +28 -0
  51. package/src/components/Tabs/Tabs.css.ts +48 -0
  52. package/src/components/Tabs/Tabs.tsx +41 -3
  53. package/src/components/TextField/TextField.tsx +49 -5
  54. package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
  55. package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
  56. package/src/components/TimelineLanes/lanes-math.ts +182 -0
  57. package/src/components/TimelineTrack/TimelineTrack.css.ts +163 -0
  58. package/src/components/TimelineTrack/TimelineTrack.tsx +90 -0
  59. package/src/components/TimelineTrack/timeline-math.ts +43 -0
  60. package/src/components/Toast/Toast.css.ts +48 -0
  61. package/src/components/Toast/Toast.tsx +37 -0
  62. package/src/components/ToolButton/ToolButton.tsx +92 -0
  63. package/src/components/ToolGroup/ToolGroup.css.ts +35 -0
  64. package/src/components/ToolGroup/ToolGroup.tsx +29 -0
  65. package/src/components/ToolboxButton/ToolboxButton.css.ts +131 -0
  66. package/src/components/ToolboxButton/ToolboxButton.tsx +88 -0
  67. package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
  68. package/src/components/TrackHeader/TrackHeader.tsx +145 -0
  69. package/src/components/TransportBar/TransportBar.css.ts +65 -0
  70. package/src/components/TransportBar/TransportBar.tsx +154 -0
  71. package/src/components/Waveform/Waveform.css.ts +77 -0
  72. package/src/components/Waveform/Waveform.tsx +195 -0
  73. package/src/components/Waveform/peaks.ts +49 -0
  74. package/src/components/index.ts +122 -0
  75. package/src/global/accordion.css.ts +23 -0
  76. package/src/global/hyperglass-base.css.ts +9 -6
  77. package/src/styles.ts +23 -0
  78. package/src/theme.css.ts +15 -0
  79. package/vite/collect-zylem-versions.ts +82 -0
  80. package/vite/index.ts +30 -0
  81. 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.class ? `sidebar zylem-scroll ${local.class}` : 'sidebar zylem-scroll'}
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 function TabsTrigger(props: {
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={props.value}
35
- onChange={props.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
- onKeyDown={props.onKeyDown as JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>}
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
- onKeyDown={props.onKeyDown as JSX.EventHandlerUnion<HTMLTextAreaElement, KeyboardEvent>}
109
+ onBlur={blur}
110
+ onKeyDown={keyDown}
67
111
  />
68
112
  </Show>
69
113
  <Show when={local.error}>