@zylem/ui 0.8.14 → 0.9.1

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 (82) hide show
  1. package/dist/chunk-YJVLOHJL.js +1 -0
  2. package/dist/index.css +1 -1
  3. package/dist/index.d.ts +7 -1
  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 +1 -1
  8. package/src/components/Accordion/Accordion.tsx +6 -1
  9. package/src/components/BuildLabel/BuildLabel.tsx +0 -1
  10. package/src/components/BuildLabel/mount-build-label.ts +0 -1
  11. package/src/components/Button/Button.css.ts +85 -66
  12. package/src/components/Button/Button.tsx +9 -3
  13. package/src/components/Callout/Callout.css.ts +1 -1
  14. package/src/components/Callout/Callout.tsx +1 -1
  15. package/src/components/Card/Card.css.ts +1 -0
  16. package/src/components/Card/Card.tsx +11 -2
  17. package/src/components/Checkbox/Checkbox.tsx +4 -2
  18. package/src/components/ColorPalette/ColorPalette.tsx +1 -1
  19. package/src/components/Console/Console.css.ts +1 -1
  20. package/src/components/Console/Console.tsx +3 -0
  21. package/src/components/Dialog/Dialog.css.ts +41 -17
  22. package/src/components/Dialog/Dialog.tsx +134 -53
  23. package/src/components/FrameStrip/FrameStrip.css.ts +1 -0
  24. package/src/components/FrameStrip/FrameStrip.tsx +2 -3
  25. package/src/components/ItemPicker/ItemPicker.tsx +46 -33
  26. package/src/components/MenuButton/MenuButton.css.ts +49 -46
  27. package/src/components/MenuButton/MenuButton.tsx +17 -5
  28. package/src/components/Menubar/Menubar.css.ts +1 -1
  29. package/src/components/Mixer/Knob.tsx +10 -3
  30. package/src/components/Mixer/Mixer.css.ts +21 -0
  31. package/src/components/Mixer/MixerStrip.tsx +29 -8
  32. package/src/components/NumberField/NumberField.css.ts +32 -26
  33. package/src/components/NumberField/NumberField.tsx +7 -2
  34. package/src/components/Panel/Panel.css.ts +1 -0
  35. package/src/components/Panel/Panel.tsx +9 -1
  36. package/src/components/PianoRoll/PianoRoll.tsx +1 -0
  37. package/src/components/Progress/Progress.css.ts +1 -1
  38. package/src/components/Progress/Progress.tsx +3 -0
  39. package/src/components/Property/Property.css.ts +13 -0
  40. package/src/components/Property/Property.tsx +3 -2
  41. package/src/components/SearchInput/SearchInput.tsx +1 -1
  42. package/src/components/Select/Select.css.ts +7 -1
  43. package/src/components/Select/Select.tsx +7 -2
  44. package/src/components/Sidebar/Sidebar.css.ts +1 -0
  45. package/src/components/Sidebar/Sidebar.tsx +23 -5
  46. package/src/components/Slider/Slider.css.ts +1 -1
  47. package/src/components/Slider/Slider.tsx +4 -2
  48. package/src/components/SpriteSizePicker/SpriteSizePicker.css.ts +2 -0
  49. package/src/components/Switch/Switch.tsx +3 -1
  50. package/src/components/Tabs/Tabs.css.ts +0 -5
  51. package/src/components/Tabs/Tabs.tsx +23 -12
  52. package/src/components/TextField/TextField.tsx +4 -0
  53. package/src/components/TimelineLanes/TimelineLanes.css.ts +7 -0
  54. package/src/components/TimelineLanes/TimelineLanes.tsx +52 -4
  55. package/src/components/TimelineLanes/lanes-math.ts +51 -0
  56. package/src/components/TimelineTrack/TimelineTrack.css.ts +3 -2
  57. package/src/components/TimelineTrack/TimelineTrack.tsx +16 -3
  58. package/src/components/TitleBar/TitleBar.css.ts +1 -0
  59. package/src/components/TitleBar/TitleBar.tsx +7 -1
  60. package/src/components/Toast/Toast.tsx +1 -2
  61. package/src/components/ToolButton/ToolButton.tsx +1 -1
  62. package/src/components/Toolbar/Toolbar.tsx +6 -1
  63. package/src/components/ToolbarButton/ToolbarButton.tsx +9 -2
  64. package/src/components/ToolboxButton/ToolboxButton.tsx +2 -1
  65. package/src/components/TrackHeader/TrackHeader.css.ts +21 -0
  66. package/src/components/TrackHeader/TrackHeader.tsx +37 -12
  67. package/src/components/TransportBar/TransportBar.css.ts +5 -0
  68. package/src/components/TransportBar/TransportBar.tsx +4 -1
  69. package/src/components/Waveform/Waveform.css.ts +10 -0
  70. package/src/components/Waveform/Waveform.tsx +59 -27
  71. package/src/components/WindowControls/WindowControls.css.ts +46 -63
  72. package/src/components/WindowControls/WindowControls.tsx +7 -6
  73. package/src/components/index.ts +1 -0
  74. package/src/components/shared/Heading.tsx +30 -0
  75. package/src/fonts.css +1 -1
  76. package/src/global/accordion.css.ts +9 -5
  77. package/src/global/common.css.ts +13 -0
  78. package/src/global/editor-base.css.ts +9 -0
  79. package/src/layers/LayerContext.tsx +2 -2
  80. package/src/sprinkles.css.ts +4 -1
  81. package/src/theme.css.ts +6 -0
  82. package/dist/chunk-V47CGFWX.js +0 -1
@@ -1,7 +1,7 @@
1
- import { Dialog as KDialog } from '@kobalte/core';
2
- import { Show } from 'solid-js';
1
+ import { createUniqueId, onCleanup, onMount, Show } from 'solid-js';
3
2
  import type { JSX } from 'solid-js';
4
- import { createDismissGuard, LayerScope, useLayer } from '../../layers';
3
+ import { Portal } from 'solid-js/web';
4
+ import { LAYER_PORTAL_CLASS, LayerScope, useLayer } from '../../layers';
5
5
  import { Button } from '../Button/Button';
6
6
  import { WindowControls } from '../WindowControls/WindowControls';
7
7
 
@@ -16,7 +16,7 @@ export interface DialogProps {
16
16
  open?: boolean;
17
17
  onOpenChange?: (open: boolean) => void;
18
18
  title?: JSX.Element;
19
- /** Secondary line under the title. */
19
+ /** Secondary line under the title. Describes the dialog unless `aria-describedby` says otherwise. */
20
20
  subtitle?: JSX.Element;
21
21
  /** Content between the titles and the close control, e.g. a search field. */
22
22
  headerExtra?: JSX.Element;
@@ -28,72 +28,147 @@ export interface DialogProps {
28
28
  class?: string;
29
29
  /** Extra class on the body, e.g. to hand scrolling to the content's columns. */
30
30
  bodyClass?: string;
31
+ /** `alertdialog` for an urgent question the user has to answer, such as a confirmation. */
32
+ role?: 'dialog' | 'alertdialog';
33
+ /** Names the dialog when it has no `title`. */
34
+ 'aria-label'?: string;
35
+ /** Id of the element that describes the dialog. */
36
+ 'aria-describedby'?: string;
31
37
  }
32
38
 
33
39
  /**
34
40
  * HyperGlass dialog: glass window with a glossy title bar and jewel close
35
41
  * control, centered over a dimmed backdrop.
36
42
  *
37
- * The body is a {@link LayerScope}, so overlays opened from inside the dialog
38
- * stack against the dialog's own content rather than against the page. A tooltip
39
- * on a dialog field floats over the dialog, but a menu belonging to the page
40
- * behind still wins — which is what keeps a dialog from leaking overlays across
41
- * the rest of the app.
43
+ * A native `<dialog>` opened with `showModal()`: it renders in the top layer,
44
+ * above everything else on the page, which is inert while it is open. Escape
45
+ * and a click outside the card ask to close it, and closing hands focus back
46
+ * to whatever had it before.
47
+ *
48
+ * The card shares a {@link LayerScope} with the scope's portal container, so
49
+ * overlays opened from the dialog render inside the `<dialog>` (outside it they
50
+ * would be inert) but beside the card rather than in it, where the card's
51
+ * clipping would cut them off.
42
52
  */
43
53
  export function Dialog(props: DialogProps) {
44
54
  const layer = useLayer('modal');
45
- const dismiss = createDismissGuard();
46
55
 
47
56
  return (
48
- <KDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
57
+ <>
49
58
  {/*
50
59
  A dialog is controlled and renders no trigger, so it has nothing in the
51
60
  page to resolve its tree from. This marker sits where the dialog was
52
61
  declared, which is enough to find an enclosing shadow root.
53
62
  */}
54
63
  <span ref={layer.anchorRef} style={{ display: 'none' }} aria-hidden="true" />
55
- <Show when={layer.mount()} keyed>
64
+ <Show when={props.open && layer.mount()} keyed>
56
65
  {(mount) => (
57
- <KDialog.Portal mount={mount}>
58
- <KDialog.Overlay class="zylem-dialog-overlay" />
59
- <div class="zylem-dialog-positioner">
60
- <KDialog.Content
61
- ref={dismiss.ref}
62
- class={props.class ? `zylem-dialog-content ${props.class}` : 'zylem-dialog-content'}
63
- data-size={props.size}
64
- onInteractOutside={dismiss.onInteractOutside}
65
- >
66
- <div class="zylem-dialog-header">
67
- <Show when={props.title || props.subtitle}>
68
- <div class="zylem-dialog-titles">
69
- <Show when={props.title}>
70
- <KDialog.Title class="zylem-dialog-title">{props.title}</KDialog.Title>
71
- </Show>
72
- <Show when={props.subtitle}>
73
- <p class="zylem-dialog-subtitle">{props.subtitle}</p>
74
- </Show>
75
- </div>
76
- </Show>
77
- <Show when={props.headerExtra}>
78
- <div class="zylem-dialog-header-extra">{props.headerExtra}</div>
79
- </Show>
80
- <WindowControls onClose={() => props.onOpenChange?.(false)} />
81
- </div>
82
- <KDialog.Description
83
- as="div"
84
- class={props.bodyClass ? `zylem-dialog-body ${props.bodyClass}` : 'zylem-dialog-body'}
85
- >
86
- <LayerScope class="zylem-dialog-scope">{props.children}</LayerScope>
87
- </KDialog.Description>
88
- <Show when={props.footer}>
89
- <div class="zylem-dialog-footer">{props.footer}</div>
90
- </Show>
91
- </KDialog.Content>
92
- </div>
93
- </KDialog.Portal>
66
+ <Portal mount={mount}>
67
+ <DialogWindow {...props} />
68
+ </Portal>
94
69
  )}
95
70
  </Show>
96
- </KDialog.Root>
71
+ </>
72
+ );
73
+ }
74
+
75
+ /** Overlays a click outside the card dismisses on its own, without closing the dialog too. */
76
+ const DISMISSABLE_OVERLAY = '[role="menu"], [role="listbox"], [role="dialog"]';
77
+
78
+ /** The `<dialog>` itself, mounted for as long as the dialog is open. */
79
+ function DialogWindow(props: DialogProps) {
80
+ const titleId = createUniqueId();
81
+ const subtitleId = createUniqueId();
82
+ let dialog!: HTMLDialogElement;
83
+ let disposed = false;
84
+ let pressedBackdrop = false;
85
+
86
+ const close = () => props.onOpenChange?.(false);
87
+ // Outside the card means on the dialog itself or on the positioner filling it.
88
+ const isBackdrop = (target: EventTarget | null | undefined) =>
89
+ target === dialog || target === dialog.firstElementChild;
90
+ const hasOpenOverlay = () =>
91
+ dialog
92
+ .querySelector(`.zylem-dialog-positioner > .${LAYER_PORTAL_CLASS}`)
93
+ ?.querySelector(DISMISSABLE_OVERLAY) != null;
94
+
95
+ onMount(() => {
96
+ // jsdom has no `showModal()`.
97
+ if (typeof dialog.showModal === 'function') dialog.showModal();
98
+ else dialog.setAttribute('open', '');
99
+
100
+ // Kobalte dismisses an open overlay from a capture listener on `document`,
101
+ // so this has to run first, on `window`, to still see it open.
102
+ const onPointerDown = (event: PointerEvent) => {
103
+ pressedBackdrop = isBackdrop(event.composedPath()[0]) && !hasOpenOverlay();
104
+ };
105
+ window.addEventListener('pointerdown', onPointerDown, true);
106
+ onCleanup(() => window.removeEventListener('pointerdown', onPointerDown, true));
107
+ });
108
+
109
+ onCleanup(() => {
110
+ disposed = true;
111
+ // Runs before the portal detaches the element: only a dialog closed while
112
+ // still in the document returns focus to whatever opened it.
113
+ if (dialog.open) dialog.close();
114
+ });
115
+
116
+ return (
117
+ <dialog
118
+ ref={dialog}
119
+ class="zylem-dialog"
120
+ role={props.role}
121
+ aria-labelledby={props.title ? titleId : undefined}
122
+ aria-label={props['aria-label']}
123
+ aria-describedby={props['aria-describedby'] ?? (props.subtitle ? subtitleId : undefined)}
124
+ onCancel={(event) => {
125
+ // `open` stays the source of truth: the host closes the dialog.
126
+ event.preventDefault();
127
+ close();
128
+ }}
129
+ onClose={() => {
130
+ // The browser closed it anyway, e.g. a second Escape it would not let be prevented.
131
+ if (!disposed) close();
132
+ }}
133
+ onClick={(event) => {
134
+ if (pressedBackdrop && isBackdrop(event.target)) close();
135
+ pressedBackdrop = false;
136
+ }}
137
+ >
138
+ <LayerScope class="zylem-dialog-positioner">
139
+ <div
140
+ class={props.class ? `zylem-dialog-content ${props.class}` : 'zylem-dialog-content'}
141
+ data-size={props.size}
142
+ >
143
+ <div class="zylem-dialog-header">
144
+ <Show when={props.title || props.subtitle}>
145
+ <div class="zylem-dialog-titles">
146
+ <Show when={props.title}>
147
+ <h2 id={titleId} class="zylem-dialog-title">
148
+ {props.title}
149
+ </h2>
150
+ </Show>
151
+ <Show when={props.subtitle}>
152
+ <p id={subtitleId} class="zylem-dialog-subtitle">
153
+ {props.subtitle}
154
+ </p>
155
+ </Show>
156
+ </div>
157
+ </Show>
158
+ <Show when={props.headerExtra}>
159
+ <div class="zylem-dialog-header-extra">{props.headerExtra}</div>
160
+ </Show>
161
+ <WindowControls onClose={close} closeLabel="Close dialog" />
162
+ </div>
163
+ <div class={props.bodyClass ? `zylem-dialog-body ${props.bodyClass}` : 'zylem-dialog-body'}>
164
+ {props.children}
165
+ </div>
166
+ <Show when={props.footer}>
167
+ <div class="zylem-dialog-footer">{props.footer}</div>
168
+ </Show>
169
+ </div>
170
+ </LayerScope>
171
+ </dialog>
97
172
  );
98
173
  }
99
174
 
@@ -110,21 +185,27 @@ export interface ConfirmDialogProps {
110
185
 
111
186
  /**
112
187
  * Confirmation dialog built on Dialog — a drop-in replacement for
113
- * `window.confirm()` flows.
188
+ * `window.confirm()` flows. An `alertdialog` described by its message; it
189
+ * opens with focus on Confirm, or on Cancel when the action is destructive.
114
190
  */
115
191
  export function ConfirmDialog(props: ConfirmDialogProps) {
192
+ const messageId = createUniqueId();
193
+
116
194
  return (
117
195
  <Dialog
118
196
  open={props.open}
119
197
  onOpenChange={props.onOpenChange}
198
+ role="alertdialog"
199
+ aria-describedby={messageId}
120
200
  title={props.title ?? 'Confirm'}
121
201
  footer={
122
202
  <>
123
- <Button variant="ghost" onClick={() => props.onOpenChange(false)}>
203
+ <Button variant="ghost" autofocus={props.danger} onClick={() => props.onOpenChange(false)}>
124
204
  {props.cancelLabel ?? 'Cancel'}
125
205
  </Button>
126
206
  <Button
127
207
  variant={props.danger ? 'danger' : 'primary'}
208
+ autofocus={!props.danger}
128
209
  onClick={() => {
129
210
  props.onConfirm();
130
211
  props.onOpenChange(false);
@@ -135,7 +216,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
135
216
  </>
136
217
  }
137
218
  >
138
- {props.message}
219
+ <div id={messageId}>{props.message}</div>
139
220
  </Dialog>
140
221
  );
141
222
  }
@@ -101,6 +101,7 @@ globalStyle('.zylem-frame-thumb-index', {
101
101
  alignItems: 'center',
102
102
  justifyContent: 'center',
103
103
  flex: '1 1 auto',
104
+ fontFamily: vars.typography.fontFamilyMono,
104
105
  fontSize: '11px',
105
106
  fontWeight: 600,
106
107
  fontVariantNumeric: 'tabular-nums',
@@ -28,7 +28,7 @@ export function FrameStrip(props: FrameStripProps) {
28
28
  <span class="zylem-frame-strip-title">{local.title}</span>
29
29
  </Show>
30
30
  </div>
31
- <div class="zylem-frame-strip-row zylem-scroll" role="listbox" aria-label={local.label ?? 'Frames'}>
31
+ <div class="zylem-frame-strip-row zylem-scroll" role="group" aria-label={local.label ?? 'Frames'}>
32
32
  {local.children}
33
33
  </div>
34
34
  </div>
@@ -51,10 +51,9 @@ export function FrameThumb(props: FrameThumbProps) {
51
51
  return (
52
52
  <button
53
53
  type="button"
54
- role="option"
55
54
  class={props.class ? `zylem-frame-thumb ${props.class}` : 'zylem-frame-thumb'}
56
55
  data-selected={props.selected ? '' : undefined}
57
- aria-selected={props.selected ? 'true' : 'false'}
56
+ aria-pressed={props.selected ? 'true' : 'false'}
58
57
  aria-label={props.label ?? `Frame ${props.index}`}
59
58
  onClick={props.onClick}
60
59
  >
@@ -1,11 +1,14 @@
1
- import { For, Show, createMemo, createSignal, type JSX } from 'solid-js';
1
+ import { For, Show, createMemo, createSignal, createUniqueId, type JSX } from 'solid-js';
2
2
  import { SearchInput } from '../SearchInput/SearchInput';
3
3
  import { filterItems, groupItems, type PickerItem } from './filter-items';
4
4
 
5
5
  export interface ItemPickerProps<T extends PickerItem = PickerItem> {
6
6
  items: T[];
7
7
  onSelect: (item: T) => void;
8
- /** Marked as the current choice. */
8
+ /**
9
+ * Marked as the current choice. Given at all (`null` for none), the items
10
+ * become toggles that expose it as `aria-pressed`.
11
+ */
9
12
  selectedId?: string | null | undefined;
10
13
  /** Hide the search field for a list short enough not to need one. */
11
14
  searchable?: boolean | undefined;
@@ -34,6 +37,9 @@ export function ItemPicker<T extends PickerItem>(props: ItemPickerProps<T>) {
34
37
 
35
38
  const searchable = () => props.searchable !== false;
36
39
  const groups = createMemo(() => groupItems(filterItems(props.items, query())));
40
+ /** Items are toggles only when the caller tracks a choice (`null` is "none yet"). */
41
+ const pressed = (item: T) =>
42
+ props.selectedId === undefined ? undefined : props.selectedId === item.id;
37
43
 
38
44
  return (
39
45
  <div class={props.class ? `zylem-picker ${props.class}` : 'zylem-picker'}>
@@ -68,37 +74,44 @@ export function ItemPicker<T extends PickerItem>(props: ItemPickerProps<T>) {
68
74
  >
69
75
  <div class="zylem-picker__groups zylem-scroll">
70
76
  <For each={groups()}>
71
- {(group) => (
72
- <div class="zylem-picker__group">
73
- <h4 class="zylem-picker__group-title">{group.group}</h4>
74
- <For each={group.items}>
75
- {(item) => (
76
- <button
77
- type="button"
78
- class="zylem-picker__item"
79
- disabled={item.disabled}
80
- data-selected={
81
- props.selectedId === item.id ? '' : undefined
82
- }
83
- title={item.description}
84
- onClick={() => props.onSelect(item)}
85
- >
86
- <Show when={item.icon}>
87
- {(icon) => (
88
- <span
89
- class="zylem-picker__item-glyph"
90
- // Markup from the caller's own registry, in the
91
- // caller's own realm; not a new trust boundary.
92
- innerHTML={icon()}
93
- />
94
- )}
95
- </Show>
96
- <span class="zylem-picker__item-label">{item.label}</span>
97
- </button>
98
- )}
99
- </For>
100
- </div>
101
- )}
77
+ {(group) => {
78
+ const titleId = createUniqueId();
79
+ return (
80
+ <div class="zylem-picker__group" role="group" aria-labelledby={titleId}>
81
+ <div id={titleId} class="zylem-picker__group-title">
82
+ {group.group}
83
+ </div>
84
+ <For each={group.items}>
85
+ {(item) => (
86
+ <button
87
+ type="button"
88
+ class="zylem-picker__item"
89
+ disabled={item.disabled}
90
+ data-selected={
91
+ props.selectedId === item.id ? '' : undefined
92
+ }
93
+ aria-pressed={pressed(item)}
94
+ title={item.description}
95
+ onClick={() => props.onSelect(item)}
96
+ >
97
+ <Show when={item.icon}>
98
+ {(icon) => (
99
+ <span
100
+ class="zylem-picker__item-glyph"
101
+ aria-hidden="true"
102
+ // Markup from the caller's own registry, in the
103
+ // caller's own realm; not a new trust boundary.
104
+ innerHTML={icon()}
105
+ />
106
+ )}
107
+ </Show>
108
+ <span class="zylem-picker__item-label">{item.label}</span>
109
+ </button>
110
+ )}
111
+ </For>
112
+ </div>
113
+ );
114
+ }}
102
115
  </For>
103
116
  </div>
104
117
  </Show>
@@ -7,15 +7,15 @@ import { vars } from '../../theme.css';
7
7
  */
8
8
 
9
9
  globalStyle('.zylem-menu-button', {
10
- position: 'relative',
11
- display: 'inline-flex',
12
- alignItems: 'stretch',
10
+ position: 'relative',
11
+ display: 'inline-flex',
12
+ alignItems: 'stretch',
13
13
  });
14
14
 
15
15
  globalStyle('.zylem-menu-button__face', {
16
- display: 'inline-flex',
17
- alignItems: 'center',
18
- justifyContent: 'center',
16
+ display: 'inline-flex',
17
+ alignItems: 'center',
18
+ justifyContent: 'center',
19
19
  });
20
20
 
21
21
  /*
@@ -24,23 +24,27 @@ globalStyle('.zylem-menu-button__face', {
24
24
  * rhythm.
25
25
  */
26
26
  globalStyle('.zylem-menu-button__chevron', {
27
- position: 'absolute',
28
- right: '-2px',
29
- bottom: '-2px',
30
- display: 'flex',
31
- alignItems: 'center',
32
- justifyContent: 'center',
33
- width: '14px',
34
- height: '14px',
35
- padding: 0,
36
- fontSize: '9px',
37
- lineHeight: 1,
38
- color: 'rgba(255, 255, 255, 0.75)',
39
- background: 'rgba(20, 24, 32, 0.92)',
40
- border: '1px solid rgba(255, 255, 255, 0.16)',
41
- borderRadius: vars.radii.control,
42
- cursor: 'pointer',
43
- transition: `color ${vars.motion.fast} ${vars.motion.easeOut}, border-color ${vars.motion.fast} ${vars.motion.easeOut}`,
27
+ position: 'absolute',
28
+ right: '-5px',
29
+ bottom: '-5px',
30
+ display: 'flex',
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ width: '18px',
34
+ height: '18px',
35
+ padding: 0,
36
+ color: vars.colors.text,
37
+ background: vars.colors.border,
38
+ border: `0.5px solid ${vars.colors.surface}`,
39
+ borderRadius: '50%',
40
+ cursor: 'pointer',
41
+ transition: `color ${vars.motion.fast} ${vars.motion.easeOut}`,
42
+ });
43
+
44
+ globalStyle('.zylem-menu-button__chevron svg', {
45
+ width: '18px',
46
+ height: '18px',
47
+ stroke: 'currentColor',
44
48
  });
45
49
 
46
50
  /*
@@ -49,43 +53,42 @@ globalStyle('.zylem-menu-button__chevron', {
49
53
  * control, and lets clicks through to the face beneath.
50
54
  */
51
55
  globalStyle('.zylem-menu-button__chevron--static', {
52
- pointerEvents: 'none',
56
+ pointerEvents: 'none',
53
57
  });
54
58
 
55
59
  globalStyle('.zylem-menu-button__chevron:hover:not([disabled])', {
56
- color: '#fff',
57
- borderColor: 'rgba(255, 255, 255, 0.4)',
60
+ color: '#fff',
58
61
  });
59
62
 
60
63
  globalStyle('.zylem-menu-button__chevron:focus-visible', {
61
- outline: 'none',
62
- boxShadow: vars.effects.focusRing,
64
+ outline: 'none',
65
+ boxShadow: vars.effects.focusRing,
63
66
  });
64
67
 
65
68
  globalStyle('.zylem-menu-button__chevron[disabled]', {
66
- opacity: 0.45,
67
- cursor: 'not-allowed',
69
+ opacity: 0.45,
70
+ cursor: 'not-allowed',
68
71
  });
69
72
 
70
73
  globalStyle('.zylem-menu-button__panel', {
71
- zIndex: vars.layers.menu,
72
- color: vars.colors.text,
73
- fontFamily: vars.typography.fontFamily,
74
- borderRadius: vars.radii.panel,
75
- border: '1px solid rgba(97, 166, 232, 0.45)',
76
- background: vars.material.glassPanelDark,
77
- boxShadow: vars.effects.shadowPanel,
78
- backdropFilter: `blur(${vars.effects.blurMd}) saturate(1.22)`,
79
- outline: 'none',
80
- '@supports': {
81
- 'not (backdrop-filter: blur(1px))': {
82
- background: vars.colors.surface,
83
- },
84
- },
74
+ zIndex: vars.layers.menu,
75
+ color: vars.colors.text,
76
+ fontFamily: vars.typography.fontFamily,
77
+ borderRadius: vars.radii.panel,
78
+ border: '1px solid rgba(97, 166, 232, 0.45)',
79
+ background: vars.material.glassPanelDark,
80
+ boxShadow: vars.effects.shadowPanel,
81
+ backdropFilter: `blur(${vars.effects.blurMd}) saturate(1.22)`,
82
+ outline: 'none',
83
+ '@supports': {
84
+ 'not (backdrop-filter: blur(1px))': {
85
+ background: vars.colors.surface,
86
+ },
87
+ },
85
88
  });
86
89
 
87
90
  /** Icon glyphs supplied as inline SVG markup rather than components. */
88
91
  globalStyle('.zylem-menu-button__face svg', {
89
- width: '18px',
90
- height: '18px',
92
+ width: '18px',
93
+ height: '18px',
91
94
  });
@@ -1,4 +1,5 @@
1
1
  import { Popover as KPopover } from '@kobalte/core';
2
+ import CircleChevronDown from 'lucide-solid/icons/circle-chevron-down';
2
3
  import { Show, createSignal, type JSX } from 'solid-js';
3
4
  import { createDismissGuard, useLayer } from '../../layers';
4
5
  import { Tooltip } from '../Tooltip/Tooltip';
@@ -40,9 +41,12 @@ export interface MenuButtonProps {
40
41
 
41
42
  /**
42
43
  * Shown in both modes, because a chevron means "this has a menu" and that is
43
- * true whether or not the face carries its own action.
44
+ * true whether or not the face carries its own action. The circle is the icon's
45
+ * own stroke, so the badge does not draw a second plate behind it.
44
46
  */
45
- const CHEVRON = '▾';
47
+ function MenuChevron() {
48
+ return <CircleChevronDown aria-hidden="true" />;
49
+ }
46
50
 
47
51
  /**
48
52
  * A toolbar button that owns a menu, optionally split.
@@ -70,11 +74,18 @@ export function MenuButton(props: MenuButtonProps) {
70
74
  const isSplit = () => props.onAction !== undefined;
71
75
  const tooltipText = () =>
72
76
  props.tooltip === null ? null : props.tooltip ?? props.label;
77
+ /** The menu takes one side of the button, so the label takes the other. */
78
+ const tooltipPlacement = () =>
79
+ (props.placement ?? 'bottom-start').startsWith('top') ? 'bottom' : 'top';
73
80
 
74
81
  /** Wrap in a tooltip only when there is text for one. */
75
82
  const described = (content: JSX.Element) => (
76
83
  <Show when={tooltipText()} fallback={content} keyed>
77
- {(text) => <Tooltip content={text}>{content}</Tooltip>}
84
+ {(text) => (
85
+ <Tooltip content={text} placement={tooltipPlacement()}>
86
+ {content}
87
+ </Tooltip>
88
+ )}
78
89
  </Show>
79
90
  );
80
91
 
@@ -107,7 +118,7 @@ export function MenuButton(props: MenuButtonProps) {
107
118
  class="zylem-menu-button__chevron zylem-menu-button__chevron--static"
108
119
  aria-hidden="true"
109
120
  >
110
- {CHEVRON}
121
+ <MenuChevron />
111
122
  </span>
112
123
  </KPopover.Trigger>,
113
124
  )}
@@ -130,7 +141,7 @@ export function MenuButton(props: MenuButtonProps) {
130
141
  aria-label={`${props.label} options`}
131
142
  disabled={props.disabled}
132
143
  >
133
- <span aria-hidden="true">{CHEVRON}</span>
144
+ <MenuChevron />
134
145
  </KPopover.Trigger>
135
146
  </Show>
136
147
 
@@ -140,6 +151,7 @@ export function MenuButton(props: MenuButtonProps) {
140
151
  <KPopover.Content
141
152
  ref={dismiss.ref}
142
153
  class="zylem-menu-button__panel"
154
+ aria-label={props.label}
143
155
  onInteractOutside={dismiss.onInteractOutside}
144
156
  >
145
157
  {props.menu(() => setOpen(false))}
@@ -33,7 +33,7 @@ globalStyle('.zylem-menubar-trigger', {
33
33
 
34
34
  globalStyle('.zylem-menubar-trigger:hover', {
35
35
  background: 'rgba(97, 166, 232, 0.12)',
36
- color: vars.colors.primaryHover,
36
+ color: vars.colors.text,
37
37
  });
38
38
 
39
39
  globalStyle('.zylem-menubar-trigger[data-expanded]', {
@@ -1,4 +1,4 @@
1
- import { createSignal, Show } from 'solid-js';
1
+ import { createSignal, createUniqueId, Show } from 'solid-js';
2
2
  import { clamp } from '../TimelineLanes/lanes-math';
3
3
 
4
4
  export interface KnobProps {
@@ -11,7 +11,10 @@ export interface KnobProps {
11
11
  step?: number;
12
12
  /** Double-click resets to this. */
13
13
  defaultValue?: number;
14
+ /** Caption under the knob, which also names it. */
14
15
  label?: string;
16
+ /** Accessible name when there is no visible `label`. */
17
+ 'aria-label'?: string;
15
18
  /** Readout under the knob; defaults to the value with `step` precision. */
16
19
  format?: (value: number) => string;
17
20
  /** Draw the arc from the centre (for pan and other ±ranges). */
@@ -48,6 +51,7 @@ export function Knob(props: KnobProps) {
48
51
  const max = () => props.max ?? 1;
49
52
  const step = () => props.step ?? (max() - min()) / 100;
50
53
  const size = () => props.size ?? 36;
54
+ const labelId = createUniqueId();
51
55
  const ratio = () => (max() > min() ? clamp((props.value - min()) / (max() - min()), 0, 1) : 0);
52
56
  const angle = () => START_ANGLE + ratio() * SWEEP;
53
57
  const zeroAngle = () =>
@@ -147,7 +151,8 @@ export function Knob(props: KnobProps) {
147
151
  class="zylem-knob-dial"
148
152
  role="slider"
149
153
  tabIndex={props.disabled ? -1 : 0}
150
- aria-label={props.label}
154
+ aria-labelledby={props.label ? labelId : undefined}
155
+ aria-label={props.label ? undefined : props['aria-label']}
151
156
  aria-valuemin={min()}
152
157
  aria-valuemax={max()}
153
158
  aria-valuenow={props.value}
@@ -180,7 +185,9 @@ export function Knob(props: KnobProps) {
180
185
  </svg>
181
186
  </div>
182
187
  <Show when={props.label}>
183
- <span class="zylem-knob-label">{props.label}</span>
188
+ <span id={labelId} class="zylem-knob-label">
189
+ {props.label}
190
+ </span>
184
191
  </Show>
185
192
  <span class="zylem-knob-value">{readout()}</span>
186
193
  </div>