@zylem/ui 0.8.9 → 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 (51) hide show
  1. package/dist/styles.css +1 -1
  2. package/package.json +12 -10
  3. package/src/components/Accordion/Accordion.tsx +10 -1
  4. package/src/components/AddTile/AddTile.css.ts +17 -0
  5. package/src/components/AddTile/AddTile.tsx +15 -7
  6. package/src/components/Callout/Callout.css.ts +45 -0
  7. package/src/components/Callout/Callout.tsx +32 -0
  8. package/src/components/Card/Card.css.ts +18 -0
  9. package/src/components/Card/Card.tsx +25 -2
  10. package/src/components/Dialog/Dialog.css.ts +69 -2
  11. package/src/components/Dialog/Dialog.tsx +35 -7
  12. package/src/components/DropdownMenu/DropdownMenu.css.ts +22 -0
  13. package/src/components/DropdownMenu/DropdownMenu.tsx +67 -20
  14. package/src/components/EmptyState/EmptyState.css.ts +9 -0
  15. package/src/components/EmptyState/EmptyState.tsx +3 -1
  16. package/src/components/FrameStrip/FrameStrip.css.ts +6 -0
  17. package/src/components/FrameStrip/FrameStrip.tsx +30 -2
  18. package/src/components/Menubar/Menubar.css.ts +52 -0
  19. package/src/components/Menubar/Menubar.tsx +77 -0
  20. package/src/components/Mixer/Knob.tsx +188 -0
  21. package/src/components/Mixer/Mixer.css.ts +246 -0
  22. package/src/components/Mixer/MixerStrip.tsx +150 -0
  23. package/src/components/NumberField/NumberField.tsx +5 -1
  24. package/src/components/Panel/Panel.tsx +6 -5
  25. package/src/components/PianoRoll/PianoRoll.css.ts +224 -0
  26. package/src/components/PianoRoll/PianoRoll.tsx +353 -0
  27. package/src/components/PianoRoll/piano-roll-math.ts +89 -0
  28. package/src/components/Property/Property.css.ts +1 -1
  29. package/src/components/Property/Property.tsx +3 -0
  30. package/src/components/Sidebar/Sidebar.css.ts +8 -0
  31. package/src/components/Sidebar/Sidebar.tsx +10 -2
  32. package/src/components/Slider/Slider.tsx +6 -0
  33. package/src/components/SpriteSizePicker/SpriteSizePicker.tsx +15 -4
  34. package/src/components/Tabs/Tabs.css.ts +48 -0
  35. package/src/components/Tabs/Tabs.tsx +41 -3
  36. package/src/components/TextField/TextField.tsx +49 -5
  37. package/src/components/TimelineLanes/TimelineLanes.css.ts +365 -0
  38. package/src/components/TimelineLanes/TimelineLanes.tsx +509 -0
  39. package/src/components/TimelineLanes/lanes-math.ts +182 -0
  40. package/src/components/Toast/Toast.css.ts +48 -0
  41. package/src/components/Toast/Toast.tsx +37 -0
  42. package/src/components/TrackHeader/TrackHeader.css.ts +162 -0
  43. package/src/components/TrackHeader/TrackHeader.tsx +145 -0
  44. package/src/components/TransportBar/TransportBar.css.ts +65 -0
  45. package/src/components/TransportBar/TransportBar.tsx +154 -0
  46. package/src/components/Waveform/Waveform.css.ts +77 -0
  47. package/src/components/Waveform/Waveform.tsx +195 -0
  48. package/src/components/Waveform/peaks.ts +49 -0
  49. package/src/components/index.ts +55 -1
  50. package/src/global/accordion.css.ts +23 -0
  51. package/src/styles.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zylem/ui",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Design tokens, global styles, and Solid components for Zylem, built with vanilla-extract",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,15 @@
49
49
  "src",
50
50
  "vite"
51
51
  ],
52
+ "scripts": {
53
+ "build": "tsup",
54
+ "prepublishOnly": "NODE_ENV=production tsup",
55
+ "dev": "tsup --watch",
56
+ "showcase": "vite --host",
57
+ "test": "vitest run",
58
+ "test:watch": "vitest",
59
+ "lint": "biome check ."
60
+ },
52
61
  "peerDependencies": {
53
62
  "solid-js": "^1.9.9"
54
63
  },
@@ -82,12 +91,5 @@
82
91
  "node": ">=22.12.0",
83
92
  "pnpm": ">=10.32.1"
84
93
  },
85
- "scripts": {
86
- "build": "tsup",
87
- "dev": "tsup --watch",
88
- "showcase": "vite --host",
89
- "test": "vitest run",
90
- "test:watch": "vitest",
91
- "lint": "biome check ."
92
- }
93
- }
94
+ "packageManager": "pnpm@10.33.0"
95
+ }
@@ -18,6 +18,13 @@ export interface AccordionProps {
18
18
  class?: string;
19
19
  /** Extra class applied to each content region (e.g. scroll helpers). */
20
20
  contentClass?: string;
21
+ /**
22
+ * Inspector layout: sections take their natural height and the accordion
23
+ * grows with them, so the surrounding panel scrolls as a whole. Without it
24
+ * the accordion fills its container and expanded sections share the space
25
+ * and scroll individually (the editor sidebar behaviour).
26
+ */
27
+ stack?: boolean;
21
28
  }
22
29
 
23
30
  /**
@@ -28,7 +35,9 @@ export interface AccordionProps {
28
35
  export function Accordion(props: AccordionProps) {
29
36
  return (
30
37
  <KAccordion.Root
31
- class={props.class ? `zylem-accordion ${props.class}` : 'zylem-accordion'}
38
+ class={['zylem-accordion', props.stack ? 'zylem-accordion--stack' : '', props.class ?? '']
39
+ .filter(Boolean)
40
+ .join(' ')}
32
41
  multiple={props.multiple}
33
42
  collapsible={props.collapsible ?? true}
34
43
  value={props.value}
@@ -36,6 +36,14 @@ globalStyle('.zylem-add-tile[data-variant="tall"]', {
36
36
  height: `calc(${FRAME_SIZE} + 18px)`,
37
37
  });
38
38
 
39
+ // Stands in for a card: fills its grid cell and rounds like one.
40
+ globalStyle('.zylem-add-tile[data-variant="fill"]', {
41
+ width: '100%',
42
+ height: '100%',
43
+ minHeight: '110px',
44
+ borderRadius: vars.radii.card,
45
+ });
46
+
39
47
  globalStyle('.zylem-add-tile:hover:not([disabled])', {
40
48
  color: '#E8F4FF',
41
49
  borderColor: 'rgba(178, 219, 255, 0.9)',
@@ -70,9 +78,18 @@ globalStyle('.zylem-add-tile[data-variant="tall"] .zylem-add-tile-icon', {
70
78
  height: '20px',
71
79
  });
72
80
 
81
+ globalStyle('.zylem-add-tile[data-variant="fill"] .zylem-add-tile-icon', {
82
+ width: '24px',
83
+ height: '24px',
84
+ });
85
+
73
86
  globalStyle('.zylem-add-tile-label', {
74
87
  fontSize: '11px',
75
88
  fontWeight: 600,
76
89
  lineHeight: 1,
77
90
  whiteSpace: 'nowrap',
78
91
  });
92
+
93
+ globalStyle('.zylem-add-tile[data-variant="fill"] .zylem-add-tile-label', {
94
+ fontSize: '13px',
95
+ });
@@ -1,35 +1,43 @@
1
1
  import Plus from 'lucide-solid/icons/plus';
2
2
  import { Show } from 'solid-js';
3
3
 
4
+ export type AddTileVariant = 'square' | 'tall' | 'fill';
5
+
4
6
  export interface AddTileProps {
5
- /** Text under the plus (tall variant) and the accessible name. */
7
+ /** Text under the plus (tall and fill variants) and the accessible name. */
6
8
  label?: string;
7
- /** `square` matches a swatch; `tall` matches a frame thumb with room for text. */
8
- variant?: 'square' | 'tall';
9
+ /**
10
+ * `square` matches a swatch; `tall` matches a frame thumb with room for
11
+ * text; `fill` takes the whole grid cell it is placed in, so it can stand
12
+ * in for a card ("New project") in a card grid.
13
+ */
14
+ variant?: AddTileVariant;
9
15
  onClick?: (event: MouseEvent) => void;
10
16
  disabled?: boolean;
11
17
  class?: string;
12
18
  }
13
19
 
14
20
  /**
15
- * Dashed "add" tile that sits at the end of a swatch row or frame strip. The
16
- * plus is centered on its own; the tall variant stacks a label beneath it.
21
+ * Dashed "add" tile that sits at the end of a swatch row, frame strip, or
22
+ * card grid. The plus is centered on its own; the tall and fill variants
23
+ * stack a label beneath it.
17
24
  */
18
25
  export function AddTile(props: AddTileProps) {
19
26
  const variant = () => props.variant ?? 'square';
20
27
  const label = () => props.label ?? 'Add';
28
+ const showsLabel = () => variant() !== 'square' && Boolean(props.label);
21
29
  return (
22
30
  <button
23
31
  type="button"
24
32
  class={props.class ? `zylem-add-tile ${props.class}` : 'zylem-add-tile'}
25
33
  data-variant={variant()}
26
- aria-label={variant() === 'square' || !props.label ? label() : undefined}
34
+ aria-label={showsLabel() ? undefined : label()}
27
35
  title={variant() === 'square' ? label() : undefined}
28
36
  disabled={props.disabled}
29
37
  onClick={props.onClick}
30
38
  >
31
39
  <Plus class="zylem-icon zylem-add-tile-icon" aria-hidden="true" />
32
- <Show when={variant() === 'tall' && props.label}>
40
+ <Show when={showsLabel()}>
33
41
  <span class="zylem-add-tile-label">{props.label}</span>
34
42
  </Show>
35
43
  </button>
@@ -0,0 +1,45 @@
1
+ import { globalStyle } from '@vanilla-extract/css';
2
+ import { vars } from '../../theme.css';
3
+
4
+ globalStyle('.zylem-callout', {
5
+ boxSizing: 'border-box',
6
+ display: 'flex',
7
+ flexDirection: 'column',
8
+ gap: vars.spacing.xxs,
9
+ padding: `${vars.spacing.sm} ${vars.spacing.md}`,
10
+ borderLeft: '2px solid rgba(150, 210, 255, 0.7)',
11
+ borderRadius: `0 ${vars.radii.control} ${vars.radii.control} 0`,
12
+ background: 'rgba(97, 166, 232, 0.1)',
13
+ color: vars.colors.text,
14
+ fontFamily: vars.typography.fontFamily,
15
+ fontSize: `calc(${vars.typography.fontSize} - 1px)`,
16
+ lineHeight: 1.45,
17
+ });
18
+
19
+ globalStyle('.zylem-callout-title', {
20
+ fontWeight: 700,
21
+ letterSpacing: '0.02em',
22
+ });
23
+
24
+ globalStyle('.zylem-callout-message code', {
25
+ fontFamily: "'Courier New', monospace",
26
+ fontSize: '0.95em',
27
+ });
28
+
29
+ globalStyle('.zylem-callout[data-tone="warning"]', {
30
+ borderLeftColor: 'rgba(252, 211, 77, 0.8)',
31
+ background: 'rgba(252, 211, 77, 0.09)',
32
+ color: '#FDE68A',
33
+ });
34
+
35
+ globalStyle('.zylem-callout[data-tone="error"]', {
36
+ borderLeftColor: 'rgba(255, 132, 114, 0.85)',
37
+ background: 'rgba(230, 69, 52, 0.14)',
38
+ color: '#FFD9D3',
39
+ });
40
+
41
+ globalStyle('.zylem-callout[data-tone="success"]', {
42
+ borderLeftColor: 'rgba(120, 255, 150, 0.7)',
43
+ background: 'rgba(60, 200, 100, 0.12)',
44
+ color: '#D9FFE3',
45
+ });
@@ -0,0 +1,32 @@
1
+ import { Show } from 'solid-js';
2
+ import type { JSX } from 'solid-js';
3
+
4
+ export type CalloutTone = 'info' | 'warning' | 'error' | 'success';
5
+
6
+ export interface CalloutProps {
7
+ tone?: CalloutTone;
8
+ /** Short bold lead-in before the message. */
9
+ title?: JSX.Element;
10
+ children: JSX.Element;
11
+ class?: string;
12
+ }
13
+
14
+ /**
15
+ * Inline note: a tinted strip with a coloured left rule for hints, warnings,
16
+ * and errors that belong next to the thing they describe (a form, a footer).
17
+ * For transient messages use `Toast`; for one-word labels use `Badge`.
18
+ */
19
+ export function Callout(props: CalloutProps) {
20
+ return (
21
+ <div
22
+ class={props.class ? `zylem-callout ${props.class}` : 'zylem-callout'}
23
+ role={props.tone === 'error' ? 'alert' : 'note'}
24
+ data-tone={props.tone ?? 'info'}
25
+ >
26
+ <Show when={props.title}>
27
+ <span class="zylem-callout-title">{props.title}</span>
28
+ </Show>
29
+ <span class="zylem-callout-message">{props.children}</span>
30
+ </div>
31
+ );
32
+ }
@@ -45,6 +45,24 @@ globalStyle('.zylem-card--selected', {
45
45
  boxShadow: `0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08), ${vars.effects.glowActive}`,
46
46
  });
47
47
 
48
+ // Artwork well: fills the card width, clips its content to the control radius.
49
+ globalStyle('.zylem-card-media', {
50
+ display: 'flex',
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ minHeight: '64px',
54
+ borderRadius: vars.radii.control,
55
+ overflow: 'hidden',
56
+ color: 'rgba(255, 255, 255, 0.85)',
57
+ });
58
+
59
+ globalStyle('.zylem-card-media > img', {
60
+ display: 'block',
61
+ width: '100%',
62
+ height: '100%',
63
+ objectFit: 'cover',
64
+ });
65
+
48
66
  globalStyle('.zylem-card-title', {
49
67
  fontSize: vars.typography.fontSize,
50
68
  fontWeight: 700,
@@ -5,24 +5,30 @@ export interface CardProps
5
5
  extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'title'> {
6
6
  title?: JSX.Element;
7
7
  description?: JSX.Element;
8
+ /** Artwork above the title: a thumbnail, preview swatch, or icon well. */
9
+ media?: JSX.Element;
8
10
  /** Renders hover glow + pointer cursor and click affordances. */
9
11
  interactive?: boolean;
10
12
  selected?: boolean;
13
+ /** Rendered after the description: tags, actions, metadata. */
11
14
  children?: JSX.Element;
12
15
  }
13
16
 
14
17
  /**
15
18
  * HyperGlass card: small index-card tile with a blue glow. Use for entity
16
- * tiles, project tiles, and asset thumbnails.
19
+ * tiles, project tiles, and asset thumbnails. Order is media, title,
20
+ * description, then children.
17
21
  */
18
22
  export function Card(props: CardProps) {
19
23
  const [local, rest] = splitProps(props, [
20
24
  'title',
21
25
  'description',
26
+ 'media',
22
27
  'interactive',
23
28
  'selected',
24
29
  'children',
25
30
  'class',
31
+ 'onKeyDown',
26
32
  ]);
27
33
 
28
34
  const classes = () =>
@@ -35,20 +41,37 @@ export function Card(props: CardProps) {
35
41
  .filter(Boolean)
36
42
  .join(' ');
37
43
 
44
+ // An interactive card is a button in all but tag name, so Enter and Space
45
+ // activate it like one.
46
+ const onKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement; target: Element }) => {
47
+ if (typeof local.onKeyDown === 'function') local.onKeyDown(event);
48
+ else if (local.onKeyDown) local.onKeyDown[0](local.onKeyDown[1], event);
49
+ if (!local.interactive || event.defaultPrevented) return;
50
+ if (event.key === 'Enter' || event.key === ' ') {
51
+ event.preventDefault();
52
+ event.currentTarget.click();
53
+ }
54
+ };
55
+
38
56
  return (
39
57
  <div
40
58
  class={classes()}
41
59
  role={local.interactive ? 'button' : undefined}
42
60
  tabIndex={local.interactive ? 0 : undefined}
61
+ aria-pressed={local.interactive && local.selected !== undefined ? local.selected : undefined}
62
+ onKeyDown={onKeyDown}
43
63
  {...rest}
44
64
  >
65
+ <Show when={local.media}>
66
+ <div class="zylem-card-media">{local.media}</div>
67
+ </Show>
45
68
  <Show when={local.title}>
46
69
  <div class="zylem-card-title">{local.title}</div>
47
70
  </Show>
48
- {local.children}
49
71
  <Show when={local.description}>
50
72
  <div class="zylem-card-description">{local.description}</div>
51
73
  </Show>
74
+ {local.children}
52
75
  </div>
53
76
  );
54
77
  }
@@ -32,8 +32,12 @@ globalStyle('.zylem-dialog-positioner', {
32
32
  });
33
33
 
34
34
  globalStyle('.zylem-dialog-content', {
35
+ display: 'flex',
36
+ flexDirection: 'column',
35
37
  width: '100%',
36
38
  maxWidth: '440px',
39
+ maxHeight: '100%',
40
+ minHeight: 0,
37
41
  borderRadius: vars.radii.window,
38
42
  border: '1px solid rgba(146, 205, 255, 0.54)',
39
43
  background: vars.material.glassPanelDark,
@@ -48,19 +52,41 @@ globalStyle('.zylem-dialog-content', {
48
52
  },
49
53
  });
50
54
 
55
+ globalStyle('.zylem-dialog-content[data-size="lg"]', {
56
+ maxWidth: '760px',
57
+ });
58
+
59
+ // Fullscreen fills the positioner (viewport minus its padding). The body takes
60
+ // whatever height the header and footer leave, so browser-style content can
61
+ // hand scrolling to its own columns.
62
+ globalStyle('.zylem-dialog-content[data-size="fullscreen"]', {
63
+ maxWidth: 'none',
64
+ height: '100%',
65
+ borderRadius: vars.radii.card,
66
+ });
67
+
51
68
  globalStyle('.zylem-dialog-header', {
69
+ flex: 'none',
52
70
  minHeight: '40px',
53
71
  display: 'flex',
54
72
  alignItems: 'center',
55
73
  justifyContent: 'space-between',
56
- gap: vars.spacing.sm,
57
- padding: `0 ${vars.spacing.md}`,
74
+ gap: vars.spacing.md,
75
+ padding: `${vars.spacing.xs} ${vars.spacing.md}`,
58
76
  background: vars.material.glossyBar,
59
77
  borderBottom: '1px solid rgba(5, 18, 32, 0.85)',
60
78
  boxShadow:
61
79
  'inset 0 1px 0 rgba(255,255,255,0.62), inset 0 -1px 0 rgba(255,255,255,0.08)',
62
80
  });
63
81
 
82
+ globalStyle('.zylem-dialog-titles', {
83
+ display: 'flex',
84
+ flexDirection: 'column',
85
+ gap: '2px',
86
+ flex: '1 1 auto',
87
+ minWidth: 0,
88
+ });
89
+
64
90
  globalStyle('.zylem-dialog-title', {
65
91
  margin: 0,
66
92
  fontSize: '13px',
@@ -71,7 +97,26 @@ globalStyle('.zylem-dialog-title', {
71
97
  textShadow: '0 1px 1px rgba(0,0,0,0.65)',
72
98
  });
73
99
 
100
+ globalStyle('.zylem-dialog-subtitle', {
101
+ margin: 0,
102
+ fontSize: `calc(${vars.typography.fontSize} - 2px)`,
103
+ fontWeight: 500,
104
+ color: 'rgba(232, 244, 255, 0.78)',
105
+ fontFamily: vars.typography.fontFamily,
106
+ textShadow: '0 1px 1px rgba(0,0,0,0.5)',
107
+ });
108
+
109
+ globalStyle('.zylem-dialog-header-extra', {
110
+ display: 'flex',
111
+ alignItems: 'center',
112
+ gap: vars.spacing.sm,
113
+ flex: 'none',
114
+ });
115
+
74
116
  globalStyle('.zylem-dialog-body', {
117
+ flex: '1 1 auto',
118
+ minHeight: 0,
119
+ overflow: 'auto',
75
120
  padding: vars.spacing.lg,
76
121
  color: vars.colors.text,
77
122
  fontFamily: vars.typography.fontFamily,
@@ -79,9 +124,31 @@ globalStyle('.zylem-dialog-body', {
79
124
  lineHeight: 1.45,
80
125
  });
81
126
 
127
+ // In fullscreen the body and its layer scope become flex columns so content
128
+ // can lay out against the dialog's height (and own its scrolling).
129
+ globalStyle('.zylem-dialog-content[data-size="fullscreen"] .zylem-dialog-body', {
130
+ display: 'flex',
131
+ flexDirection: 'column',
132
+ });
133
+
134
+ globalStyle('.zylem-dialog-content[data-size="fullscreen"] .zylem-dialog-scope', {
135
+ display: 'flex',
136
+ flexDirection: 'column',
137
+ flex: '1 1 auto',
138
+ minHeight: 0,
139
+ });
140
+
82
141
  globalStyle('.zylem-dialog-footer', {
142
+ flex: 'none',
83
143
  display: 'flex',
144
+ alignItems: 'center',
84
145
  justifyContent: 'flex-end',
85
146
  gap: vars.spacing.sm,
86
147
  padding: `0 ${vars.spacing.lg} ${vars.spacing.lg}`,
87
148
  });
149
+
150
+ globalStyle('.zylem-dialog-content[data-size="fullscreen"] .zylem-dialog-footer', {
151
+ padding: `${vars.spacing.sm} ${vars.spacing.lg}`,
152
+ borderTop: '1px solid rgba(97, 166, 232, 0.28)',
153
+ background: 'rgba(4, 12, 20, 0.6)',
154
+ });
@@ -5,13 +5,29 @@ import { createDismissGuard, LayerScope, useLayer } from '../../layers';
5
5
  import { Button } from '../Button/Button';
6
6
  import { WindowControls } from '../WindowControls/WindowControls';
7
7
 
8
+ /**
9
+ * Card width. `md` is the default 440px card, `lg` a 760px card for forms
10
+ * with two columns, and `fullscreen` fills the viewport (minus a margin) with
11
+ * the body taking the remaining height — the shell for browser-style pickers.
12
+ */
13
+ export type DialogSize = 'md' | 'lg' | 'fullscreen';
14
+
8
15
  export interface DialogProps {
9
16
  open?: boolean;
10
17
  onOpenChange?: (open: boolean) => void;
11
18
  title?: JSX.Element;
19
+ /** Secondary line under the title. */
20
+ subtitle?: JSX.Element;
21
+ /** Content between the titles and the close control, e.g. a search field. */
22
+ headerExtra?: JSX.Element;
23
+ size?: DialogSize;
12
24
  children: JSX.Element;
13
25
  /** Optional footer content (e.g. action buttons). */
14
26
  footer?: JSX.Element;
27
+ /** Extra class on the card. */
28
+ class?: string;
29
+ /** Extra class on the body, e.g. to hand scrolling to the content's columns. */
30
+ bodyClass?: string;
15
31
  }
16
32
 
17
33
  /**
@@ -43,19 +59,31 @@ export function Dialog(props: DialogProps) {
43
59
  <div class="zylem-dialog-positioner">
44
60
  <KDialog.Content
45
61
  ref={dismiss.ref}
46
- class="zylem-dialog-content"
62
+ class={props.class ? `zylem-dialog-content ${props.class}` : 'zylem-dialog-content'}
63
+ data-size={props.size}
47
64
  onInteractOutside={dismiss.onInteractOutside}
48
65
  >
49
66
  <div class="zylem-dialog-header">
50
- <Show when={props.title}>
51
- <KDialog.Title class="zylem-dialog-title">
52
- {props.title}
53
- </KDialog.Title>
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>
54
79
  </Show>
55
80
  <WindowControls onClose={() => props.onOpenChange?.(false)} />
56
81
  </div>
57
- <KDialog.Description as="div" class="zylem-dialog-body">
58
- <LayerScope>{props.children}</LayerScope>
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>
59
87
  </KDialog.Description>
60
88
  <Show when={props.footer}>
61
89
  <div class="zylem-dialog-footer">{props.footer}</div>
@@ -61,6 +61,28 @@ globalStyle('.zylem-dropdown-item--danger[data-highlighted]', {
61
61
  color: '#FFD9D3',
62
62
  });
63
63
 
64
+ globalStyle('.zylem-dropdown-label', {
65
+ flex: '1 1 auto',
66
+ minWidth: 0,
67
+ });
68
+
69
+ // Check column, reserved on every row of a menu that has a checkable item.
70
+ globalStyle('.zylem-dropdown-check', {
71
+ display: 'inline-flex',
72
+ alignItems: 'center',
73
+ justifyContent: 'center',
74
+ flex: 'none',
75
+ width: '14px',
76
+ height: '14px',
77
+ marginRight: `calc(${vars.spacing.xs} * -1)`,
78
+ });
79
+
80
+ globalStyle('.zylem-dropdown-check svg', {
81
+ width: '13px',
82
+ height: '13px',
83
+ stroke: 'currentColor',
84
+ });
85
+
64
86
  globalStyle('.zylem-dropdown-shortcut', {
65
87
  fontSize: `calc(${vars.typography.fontSize} - 3px)`,
66
88
  color: vars.colors.textSecondary,
@@ -1,4 +1,5 @@
1
1
  import { DropdownMenu as KDropdownMenu } from '@kobalte/core';
2
+ import Check from 'lucide-solid/icons/check';
2
3
  import { For, Show } from 'solid-js';
3
4
  import type { JSX } from 'solid-js';
4
5
  import { createDismissGuard, useLayer } from '../../layers';
@@ -10,6 +11,12 @@ export interface DropdownMenuItemConfig {
10
11
  shortcut?: string;
11
12
  disabled?: boolean;
12
13
  danger?: boolean;
14
+ /**
15
+ * Render as a checkable item showing this state. Selecting it still calls
16
+ * `onSelect`; the owner decides what the new state is (a plain toggle, or
17
+ * one-of-many the way a stage picker works).
18
+ */
19
+ checked?: boolean;
13
20
  /** Render a separator line above this item. */
14
21
  separatorAbove?: boolean;
15
22
  }
@@ -24,40 +31,80 @@ export interface DropdownMenuProps {
24
31
  }
25
32
 
26
33
  /**
27
- * HyperGlass dropdown menu: floating glass sheet with highlight rows.
34
+ * The rows of a menu. Shared by `DropdownMenu` and `Menubar`, whose Kobalte
35
+ * parts are the same underlying menu primitives, so it may be rendered inside
36
+ * either one's content.
28
37
  *
29
- * Portals into the layer container for its tree at the `menu` tier, so it opens
30
- * over panels and dialogs and stays styled inside a shadow root.
38
+ * A menu with any checkable row reserves the check column on every row so
39
+ * labels line up.
31
40
  */
32
- export function DropdownMenu(props: DropdownMenuProps) {
33
- const layer = useLayer('menu');
34
- const dismiss = createDismissGuard();
41
+ export function MenuItems(props: { items: DropdownMenuItemConfig[] }) {
42
+ const hasChecks = () => props.items.some((item) => item.checked !== undefined);
35
43
 
36
- const items = () => (
44
+ const body = (item: DropdownMenuItemConfig) => (
45
+ <>
46
+ <Show when={hasChecks()}>
47
+ <span class="zylem-dropdown-check" aria-hidden="true">
48
+ <Show when={item.checked !== undefined}>
49
+ <KDropdownMenu.ItemIndicator>
50
+ <Check />
51
+ </KDropdownMenu.ItemIndicator>
52
+ </Show>
53
+ </span>
54
+ </Show>
55
+ <span class="zylem-dropdown-label">{item.label}</span>
56
+ <Show when={item.shortcut}>
57
+ <span class="zylem-dropdown-shortcut">{item.shortcut}</span>
58
+ </Show>
59
+ </>
60
+ );
61
+
62
+ const itemClass = (item: DropdownMenuItemConfig) =>
63
+ item.danger ? 'zylem-dropdown-item zylem-dropdown-item--danger' : 'zylem-dropdown-item';
64
+
65
+ return (
37
66
  <For each={props.items}>
38
67
  {(item) => (
39
68
  <>
40
69
  <Show when={item.separatorAbove}>
41
70
  <KDropdownMenu.Separator class="zylem-dropdown-separator" />
42
71
  </Show>
43
- <KDropdownMenu.Item
44
- class={
45
- item.danger
46
- ? 'zylem-dropdown-item zylem-dropdown-item--danger'
47
- : 'zylem-dropdown-item'
72
+ <Show
73
+ when={item.checked !== undefined}
74
+ fallback={
75
+ <KDropdownMenu.Item
76
+ class={itemClass(item)}
77
+ disabled={item.disabled}
78
+ onSelect={item.onSelect}
79
+ >
80
+ {body(item)}
81
+ </KDropdownMenu.Item>
48
82
  }
49
- disabled={item.disabled}
50
- onSelect={item.onSelect}
51
83
  >
52
- <span>{item.label}</span>
53
- <Show when={item.shortcut}>
54
- <span class="zylem-dropdown-shortcut">{item.shortcut}</span>
55
- </Show>
56
- </KDropdownMenu.Item>
84
+ <KDropdownMenu.CheckboxItem
85
+ class={itemClass(item)}
86
+ checked={item.checked}
87
+ disabled={item.disabled}
88
+ onSelect={item.onSelect}
89
+ >
90
+ {body(item)}
91
+ </KDropdownMenu.CheckboxItem>
92
+ </Show>
57
93
  </>
58
94
  )}
59
95
  </For>
60
96
  );
97
+ }
98
+
99
+ /**
100
+ * HyperGlass dropdown menu: floating glass sheet with highlight rows.
101
+ *
102
+ * Portals into the layer container for its tree at the `menu` tier, so it opens
103
+ * over panels and dialogs and stays styled inside a shadow root.
104
+ */
105
+ export function DropdownMenu(props: DropdownMenuProps) {
106
+ const layer = useLayer('menu');
107
+ const dismiss = createDismissGuard();
61
108
 
62
109
  return (
63
110
  <KDropdownMenu.Root placement={props.placement ?? 'bottom-start'}>
@@ -72,7 +119,7 @@ export function DropdownMenu(props: DropdownMenuProps) {
72
119
  class="zylem-dropdown-content"
73
120
  onInteractOutside={dismiss.onInteractOutside}
74
121
  >
75
- {items()}
122
+ <MenuItems items={props.items} />
76
123
  </KDropdownMenu.Content>
77
124
  </KDropdownMenu.Portal>
78
125
  )}
@@ -27,3 +27,12 @@ globalStyle('.zylem-empty-state-message', {
27
27
  lineHeight: 1.45,
28
28
  maxWidth: '360px',
29
29
  });
30
+
31
+ // One or several actions side by side, wrapping on narrow hosts.
32
+ globalStyle('.zylem-empty-state-actions', {
33
+ display: 'flex',
34
+ flexWrap: 'wrap',
35
+ justifyContent: 'center',
36
+ gap: vars.spacing.sm,
37
+ marginTop: vars.spacing.xs,
38
+ });