@zylem/ui 0.9.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zylem/ui",
3
- "version": "0.9.6",
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
- "packageManager": "pnpm@10.33.0"
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
+ }
@@ -67,6 +67,20 @@ globalStyle('.zylem-button[data-variant="primary"]:hover:not([disabled])', {
67
67
  filter: 'brightness(1.08)',
68
68
  });
69
69
 
70
+ globalStyle('.zylem-button[data-on]', {
71
+ borderColor: 'rgba(46, 231, 255, 0.9)',
72
+ background: vars.material.buttonGlassOn,
73
+ boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowOn}`,
74
+ color: '#F3FDFF',
75
+ });
76
+
77
+ globalStyle('.zylem-button[data-on]:hover:not([disabled])', {
78
+ background: vars.material.buttonGlassOn,
79
+ boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowOn}`,
80
+ filter: 'brightness(1.08)',
81
+ color: '#F3FDFF',
82
+ });
83
+
70
84
  globalStyle('.zylem-button[data-variant="active"]', {
71
85
  borderColor: 'rgba(20, 255, 60, 0.78)',
72
86
  background: vars.material.buttonGlassActive,
@@ -9,18 +9,24 @@ export interface ButtonProps
9
9
  size?: ButtonSize;
10
10
  /** Icon drawn before the label. Omitted, the button is label only. */
11
11
  icon?: JSX.Element | undefined;
12
+ /**
13
+ * Checkbox-like toggle. Electric blue while on, and exposed as `aria-pressed`.
14
+ * Pass `false` while off so the button still announces itself.
15
+ */
16
+ on?: boolean | undefined;
12
17
  }
13
18
 
14
19
  /**
15
20
  * HyperGlass button: squared Aqua-like bevel with glass depth. Variants map
16
- * to the Zylem palette — `active` for live/run actions, `danger` for
17
- * destructive actions, `ghost` for low-emphasis chrome.
21
+ * to the Zylem palette — `active` for live/run actions, `on` for a toggle
22
+ * that is switched on, `danger` for destructive actions, `ghost` for
23
+ * low-emphasis chrome.
18
24
  *
19
25
  * Styling lives on the global `.zylem-button` class (bundled in
20
26
  * `@zylem/ui/styles.css`), selected via `data-variant` / `data-size`.
21
27
  */
22
28
  export function Button(props: ButtonProps) {
23
- const [local, rest] = splitProps(props, ['variant', 'size', 'class', 'children', 'icon']);
29
+ const [local, rest] = splitProps(props, ['variant', 'size', 'class', 'children', 'icon', 'on']);
24
30
  return (
25
31
  <button
26
32
  type="button"
@@ -28,6 +34,8 @@ export function Button(props: ButtonProps) {
28
34
  data-variant={local.variant ?? 'default'}
29
35
  data-size={local.size ?? 'md'}
30
36
  {...rest}
37
+ data-on={local.on ? '' : undefined}
38
+ aria-pressed={local.on === undefined ? undefined : local.on}
31
39
  >
32
40
  <Show when={local.icon}>
33
41
  <span class="zylem-button-icon" aria-hidden="true">
@@ -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 (!props.onChange || Number.isNaN(value) || value === props.value) return;
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
- <span class={`zylem-property-value ${props.valueClass ?? ''}`}>
106
- {display().element}
107
- </span>
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 (max <= min) pins everything to 0.
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,
@@ -81,6 +81,7 @@ export function ToolButton(props: ToolButtonProps) {
81
81
  return (
82
82
  <ToolbarButton
83
83
  label={label()}
84
+ selected={props.selected}
84
85
  pressed={props.selected ?? false}
85
86
  disabled={props.disabled}
86
87
  onClick={props.onClick}
@@ -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',
@@ -48,12 +49,37 @@ globalStyle('.zylem-toolbar-btn:focus-visible', {
48
49
  boxShadow: `${vars.effects.shadowButton}, ${vars.effects.focusRing}`,
49
50
  });
50
51
 
52
+ globalStyle('.zylem-toolbar-btn[data-on]', {
53
+ borderColor: 'rgba(46, 231, 255, 0.9)',
54
+ background: vars.material.buttonGlassOn,
55
+ boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowOn}`,
56
+ color: '#F3FDFF',
57
+ });
58
+
59
+ globalStyle('.zylem-toolbar-btn[data-on]:hover', {
60
+ background: vars.material.buttonGlassOn,
61
+ boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowOn}`,
62
+ filter: 'brightness(1.08)',
63
+ });
64
+
51
65
  globalStyle('.zylem-toolbar-btn[data-selected], .zylem-toolbar-btn.selected', {
52
66
  borderColor: 'rgba(20, 255, 60, 0.78)',
53
67
  background: vars.material.buttonGlassActive,
54
68
  boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowActive}`,
55
69
  });
56
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
+
57
83
  globalStyle('.zylem-toolbar-btn[disabled]', {
58
84
  opacity: 0.45,
59
85
  cursor: 'not-allowed',
@@ -7,10 +7,24 @@ export interface ToolbarButtonProps {
7
7
  onClick?: (event: MouseEvent) => void;
8
8
  /**
9
9
  * The selected look alone, for a button whose label already says its state
10
- * (Play / Pause). For a toggle with a constant label, use `pressed`.
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;
13
- /** Makes the button a toggle: the state is exposed as `aria-pressed` and drawn as selected. */
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;
19
+ /**
20
+ * Checkbox-like toggle. Electric blue while on, and exposed as `aria-pressed`.
21
+ * Pass `false` for a toggle that is off so the button still announces itself.
22
+ */
23
+ on?: boolean;
24
+ /**
25
+ * Toggle drawn as `on`, unless `selected` is also set, which keeps the
26
+ * live-green jewel. Prefer `on` for new toggles.
27
+ */
14
28
  pressed?: boolean;
15
29
  disabled?: boolean;
16
30
  children: JSX.Element;
@@ -28,7 +42,8 @@ function insidePopup(element: Element | undefined): boolean {
28
42
 
29
43
  /**
30
44
  * HyperGlass toolbar button: square glass icon button with a tooltip.
31
- * Selected and pressed states render as a 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.
32
47
  *
33
48
  * The tooltip portals into the layer container for its own tree, so it stays
34
49
  * styled when the toolbar lives inside a web component's shadow root.
@@ -58,7 +73,7 @@ export function ToolbarButton(props: ToolbarButtonProps) {
58
73
  >
59
74
  <KButton.Root
60
75
  aria-label={props.label}
61
- aria-pressed={props.pressed}
76
+ aria-pressed={props.on !== undefined ? props.on : props.pressed}
62
77
  onClick={props.onClick}
63
78
  disabled={props.disabled}
64
79
  class={
@@ -66,7 +81,9 @@ export function ToolbarButton(props: ToolbarButtonProps) {
66
81
  ? `zylem-toolbar-btn ${props.class}`
67
82
  : 'zylem-toolbar-btn'
68
83
  }
69
- data-selected={props.selected || props.pressed ? '' : undefined}
84
+ data-selected={props.selected && !props.danger ? '' : undefined}
85
+ data-danger={props.selected && props.danger ? '' : undefined}
86
+ data-on={props.on || (props.pressed && !props.selected) ? '' : undefined}
70
87
  >
71
88
  {props.children}
72
89
  </KButton.Root>
@@ -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">
@@ -50,6 +50,19 @@ globalStyle('.zylem-toolbox-btn:focus-visible', {
50
50
  boxShadow: `${vars.effects.shadowButton}, ${vars.effects.focusRing}`,
51
51
  });
52
52
 
53
+ globalStyle('.zylem-toolbox-btn[data-on]', {
54
+ borderColor: 'rgba(46, 231, 255, 0.9)',
55
+ background: vars.material.buttonGlassOn,
56
+ boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowOn}`,
57
+ color: '#F3FDFF',
58
+ });
59
+
60
+ globalStyle('.zylem-toolbox-btn[data-on]:hover:not([disabled])', {
61
+ background: vars.material.buttonGlassOn,
62
+ boxShadow: `${vars.effects.shadowButton}, ${vars.effects.glowOn}`,
63
+ filter: 'brightness(1.08)',
64
+ });
65
+
53
66
  globalStyle('.zylem-toolbox-btn[data-selected]', {
54
67
  borderColor: 'rgba(20, 255, 60, 0.78)',
55
68
  background: vars.material.buttonGlassActive,
@@ -11,8 +11,13 @@ export interface ToolboxButtonProps {
11
11
  label: string;
12
12
  /** Icon rendered above the label. */
13
13
  children: JSX.Element;
14
- /** Given at all, the tile is a toggle and exposes this as `aria-pressed`. */
14
+ /** Live-green jewel. Given at all, the tile exposes this as `aria-pressed`. */
15
15
  selected?: boolean;
16
+ /**
17
+ * Checkbox-like toggle. Electric blue while on. Given at all, the tile
18
+ * exposes this as `aria-pressed` instead of `selected`.
19
+ */
20
+ on?: boolean;
16
21
  disabled?: boolean;
17
22
  /** Overrides the size inherited from the enclosing `ToolboxGrid`. */
18
23
  size?: ToolboxSize;
@@ -30,7 +35,8 @@ export function ToolboxButton(props: ToolboxButtonProps) {
30
35
  class={props.class ? `zylem-toolbox-btn ${props.class}` : 'zylem-toolbox-btn'}
31
36
  data-size={props.size}
32
37
  data-selected={props.selected ? '' : undefined}
33
- aria-pressed={props.selected}
38
+ data-on={props.on ? '' : undefined}
39
+ aria-pressed={props.on !== undefined ? props.on : props.selected}
34
40
  disabled={props.disabled}
35
41
  onClick={props.onClick}
36
42
  >
@@ -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 the top and bottom edges. */
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
- const DOCK_OPTIONS: readonly { id: DockPosition; label: string }[] = [
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. Only controls with a handler are rendered.
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={DOCK_OPTIONS.map((option) => ({
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,