@zylem/ui 0.1.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-3TP2SNNW.js +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +22 -2
- package/dist/index.js +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.js +1 -1
- package/package.json +14 -5
- package/src/components/Button/Button.css.ts +3 -0
- package/src/components/Console/Console.css.ts +4 -2
- package/src/components/Dialog/Dialog.css.ts +3 -2
- package/src/components/Dialog/Dialog.tsx +44 -20
- package/src/components/DropdownMenu/DropdownMenu.css.ts +1 -1
- package/src/components/DropdownMenu/DropdownMenu.tsx +47 -28
- package/src/components/ItemPicker/ItemPicker.css.ts +101 -0
- package/src/components/ItemPicker/ItemPicker.tsx +108 -0
- package/src/components/ItemPicker/filter-items.ts +75 -0
- package/src/components/MenuButton/MenuButton.css.ts +91 -0
- package/src/components/MenuButton/MenuButton.tsx +153 -0
- package/src/components/Property/Property.tsx +8 -6
- package/src/components/SearchInput/SearchInput.tsx +3 -0
- package/src/components/Select/Select.css.ts +1 -1
- package/src/components/Select/Select.tsx +17 -5
- package/src/components/ToolbarButton/ToolbarButton.css.ts +3 -0
- package/src/components/ToolbarButton/ToolbarButton.tsx +17 -7
- package/src/components/Tooltip/Tooltip.css.ts +4 -1
- package/src/components/Tooltip/Tooltip.tsx +26 -8
- package/src/components/WindowControls/WindowControls.tsx +7 -5
- package/src/components/index.ts +27 -0
- package/src/global/detached-panel.css.ts +4 -1
- package/src/global/editor-base.css.ts +32 -10
- package/src/global/hyperglass-base.css.ts +80 -5
- package/src/layers/LayerContext.tsx +132 -0
- package/src/layers/dismiss-guard.ts +81 -0
- package/src/layers/index.ts +27 -0
- package/src/layers/layer-tokens.css.ts +38 -0
- package/src/layers/resolve-mount.ts +48 -0
- package/src/layers/tiers.ts +53 -0
- package/src/styles.ts +3 -0
- package/src/theme.css.ts +7 -0
- package/dist/chunk-VGOOWBSO.js +0 -1
package/dist/styles.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import"./chunk-
|
|
1
|
+
import"./chunk-3TP2SNNW.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zylem/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Design tokens, global styles, and Solid components for Zylem, built with vanilla-extract",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
"src"
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
|
-
"zylem": "node --experimental-strip-types ./scripts/zylem-runner.ts",
|
|
45
44
|
"build": "tsup",
|
|
46
45
|
"prepublishOnly": "NODE_ENV=production tsup",
|
|
47
46
|
"dev": "tsup --watch",
|
|
48
47
|
"showcase": "vite --host",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"test:watch": "vitest",
|
|
49
50
|
"lint": "biome check ."
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
@@ -59,13 +60,16 @@
|
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@biomejs/biome": "^2.3.7",
|
|
61
62
|
"@clack/prompts": "^1.6.0",
|
|
63
|
+
"@solidjs/testing-library": "^0.8.10",
|
|
62
64
|
"@vanilla-extract/esbuild-plugin": "^2.3.22",
|
|
63
65
|
"@vanilla-extract/vite-plugin": "^5.2.2",
|
|
66
|
+
"happy-dom": "^20.11.2",
|
|
64
67
|
"solid-js": "^1.9.9",
|
|
65
68
|
"tsup": "^8.5.1",
|
|
66
69
|
"typescript": "6.0.3",
|
|
67
70
|
"vite": "7.0.4",
|
|
68
|
-
"vite-plugin-solid": "^2.11.8"
|
|
71
|
+
"vite-plugin-solid": "^2.11.8",
|
|
72
|
+
"vitest": "^4.1.10"
|
|
69
73
|
},
|
|
70
74
|
"dependencies": {
|
|
71
75
|
"@kobalte/core": "^0.13.11",
|
|
@@ -73,5 +77,10 @@
|
|
|
73
77
|
"@vanilla-extract/recipes": "^0.5.7",
|
|
74
78
|
"@vanilla-extract/sprinkles": "^1.6.5",
|
|
75
79
|
"lucide-solid": "^0.511.0"
|
|
76
|
-
}
|
|
77
|
-
|
|
80
|
+
},
|
|
81
|
+
"engines": {
|
|
82
|
+
"node": ">=22.12.0",
|
|
83
|
+
"pnpm": ">=10.32.1"
|
|
84
|
+
},
|
|
85
|
+
"packageManager": "pnpm@10.33.0"
|
|
86
|
+
}
|
|
@@ -41,6 +41,9 @@ globalStyle('.zylem-button:hover:not([disabled])', {
|
|
|
41
41
|
globalStyle('.zylem-button:active:not([disabled])', {
|
|
42
42
|
transform: 'translateY(1px)',
|
|
43
43
|
filter: 'brightness(0.92)',
|
|
44
|
+
// CodePen-inspired press: compressed drop shadow + bright bottom rim flash.
|
|
45
|
+
boxShadow:
|
|
46
|
+
'inset 0 1px 0 rgba(255,255,255,0.22), inset 0 -1px 0 rgba(255,255,255,0.55), inset 0 2px 3px rgba(0,0,0,0.28), 0 1px 3px rgba(0,0,0,0.28)',
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
globalStyle('.zylem-button:focus-visible', {
|
|
@@ -56,8 +56,10 @@ globalStyle('.zylem-console-actions', {
|
|
|
56
56
|
zIndex: 1,
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
// Legacy hook used by the editor's clear button.
|
|
60
|
-
|
|
59
|
+
// Legacy hook used by the editor's clear button. Keep specificity above the
|
|
60
|
+
// HyperGlass sheen host rule (`.zylem-hyperglass-root .zylem-button`) so the
|
|
61
|
+
// overlay stays absolutely positioned in the console wrapper.
|
|
62
|
+
globalStyle('.zylem-console-clear.zylem-button, .zylem-console-clear', {
|
|
61
63
|
position: 'absolute',
|
|
62
64
|
top: vars.spacing.sm,
|
|
63
65
|
right: vars.spacing.sm,
|
|
@@ -14,7 +14,7 @@ const contentEnter = keyframes({
|
|
|
14
14
|
globalStyle('.zylem-dialog-overlay', {
|
|
15
15
|
position: 'fixed',
|
|
16
16
|
inset: 0,
|
|
17
|
-
zIndex:
|
|
17
|
+
zIndex: vars.layers.modal,
|
|
18
18
|
background: 'rgba(5, 10, 18, 0.6)',
|
|
19
19
|
backdropFilter: 'blur(3px)',
|
|
20
20
|
animation: `${overlayEnter} 0.15s ${vars.motion.easeOut}`,
|
|
@@ -23,7 +23,8 @@ globalStyle('.zylem-dialog-overlay', {
|
|
|
23
23
|
globalStyle('.zylem-dialog-positioner', {
|
|
24
24
|
position: 'fixed',
|
|
25
25
|
inset: 0,
|
|
26
|
-
|
|
26
|
+
// One above the backdrop it shares a tier with.
|
|
27
|
+
zIndex: `calc(${vars.layers.modal} + 1)`,
|
|
27
28
|
display: 'flex',
|
|
28
29
|
alignItems: 'center',
|
|
29
30
|
justifyContent: 'center',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Dialog as KDialog } from '@kobalte/core';
|
|
2
2
|
import { Show } from 'solid-js';
|
|
3
3
|
import type { JSX } from 'solid-js';
|
|
4
|
+
import { createDismissGuard, LayerScope, useLayer } from '../../layers';
|
|
4
5
|
import { Button } from '../Button/Button';
|
|
5
6
|
import { WindowControls } from '../WindowControls/WindowControls';
|
|
6
7
|
|
|
@@ -16,31 +17,54 @@ export interface DialogProps {
|
|
|
16
17
|
/**
|
|
17
18
|
* HyperGlass dialog: glass window with a glossy title bar and jewel close
|
|
18
19
|
* control, centered over a dimmed backdrop.
|
|
20
|
+
*
|
|
21
|
+
* The body is a {@link LayerScope}, so overlays opened from inside the dialog
|
|
22
|
+
* stack against the dialog's own content rather than against the page. A tooltip
|
|
23
|
+
* on a dialog field floats over the dialog, but a menu belonging to the page
|
|
24
|
+
* behind still wins — which is what keeps a dialog from leaking overlays across
|
|
25
|
+
* the rest of the app.
|
|
19
26
|
*/
|
|
20
27
|
export function Dialog(props: DialogProps) {
|
|
28
|
+
const layer = useLayer('modal');
|
|
29
|
+
const dismiss = createDismissGuard();
|
|
30
|
+
|
|
21
31
|
return (
|
|
22
32
|
<KDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
{/*
|
|
34
|
+
A dialog is controlled and renders no trigger, so it has nothing in the
|
|
35
|
+
page to resolve its tree from. This marker sits where the dialog was
|
|
36
|
+
declared, which is enough to find an enclosing shadow root.
|
|
37
|
+
*/}
|
|
38
|
+
<span ref={layer.anchorRef} style={{ display: 'none' }} aria-hidden="true" />
|
|
39
|
+
<Show when={layer.mount()} keyed>
|
|
40
|
+
{(mount) => (
|
|
41
|
+
<KDialog.Portal mount={mount}>
|
|
42
|
+
<KDialog.Overlay class="zylem-dialog-overlay" />
|
|
43
|
+
<div class="zylem-dialog-positioner">
|
|
44
|
+
<KDialog.Content
|
|
45
|
+
ref={dismiss.ref}
|
|
46
|
+
class="zylem-dialog-content"
|
|
47
|
+
onInteractOutside={dismiss.onInteractOutside}
|
|
48
|
+
>
|
|
49
|
+
<div class="zylem-dialog-header">
|
|
50
|
+
<Show when={props.title}>
|
|
51
|
+
<KDialog.Title class="zylem-dialog-title">
|
|
52
|
+
{props.title}
|
|
53
|
+
</KDialog.Title>
|
|
54
|
+
</Show>
|
|
55
|
+
<WindowControls onClose={() => props.onOpenChange?.(false)} />
|
|
56
|
+
</div>
|
|
57
|
+
<KDialog.Description as="div" class="zylem-dialog-body">
|
|
58
|
+
<LayerScope>{props.children}</LayerScope>
|
|
59
|
+
</KDialog.Description>
|
|
60
|
+
<Show when={props.footer}>
|
|
61
|
+
<div class="zylem-dialog-footer">{props.footer}</div>
|
|
62
|
+
</Show>
|
|
63
|
+
</KDialog.Content>
|
|
34
64
|
</div>
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<Show when={props.footer}>
|
|
39
|
-
<div class="zylem-dialog-footer">{props.footer}</div>
|
|
40
|
-
</Show>
|
|
41
|
-
</KDialog.Content>
|
|
42
|
-
</div>
|
|
43
|
-
</KDialog.Portal>
|
|
65
|
+
</KDialog.Portal>
|
|
66
|
+
)}
|
|
67
|
+
</Show>
|
|
44
68
|
</KDialog.Root>
|
|
45
69
|
);
|
|
46
70
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DropdownMenu as KDropdownMenu } from '@kobalte/core';
|
|
2
2
|
import { For, Show } from 'solid-js';
|
|
3
3
|
import type { JSX } from 'solid-js';
|
|
4
|
+
import { createDismissGuard, useLayer } from '../../layers';
|
|
4
5
|
|
|
5
6
|
export interface DropdownMenuItemConfig {
|
|
6
7
|
id: string;
|
|
@@ -24,40 +25,58 @@ export interface DropdownMenuProps {
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* HyperGlass dropdown menu: floating glass sheet with highlight rows.
|
|
28
|
+
*
|
|
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.
|
|
27
31
|
*/
|
|
28
32
|
export function DropdownMenu(props: DropdownMenuProps) {
|
|
33
|
+
const layer = useLayer('menu');
|
|
34
|
+
const dismiss = createDismissGuard();
|
|
35
|
+
|
|
36
|
+
const items = () => (
|
|
37
|
+
<For each={props.items}>
|
|
38
|
+
{(item) => (
|
|
39
|
+
<>
|
|
40
|
+
<Show when={item.separatorAbove}>
|
|
41
|
+
<KDropdownMenu.Separator class="zylem-dropdown-separator" />
|
|
42
|
+
</Show>
|
|
43
|
+
<KDropdownMenu.Item
|
|
44
|
+
class={
|
|
45
|
+
item.danger
|
|
46
|
+
? 'zylem-dropdown-item zylem-dropdown-item--danger'
|
|
47
|
+
: 'zylem-dropdown-item'
|
|
48
|
+
}
|
|
49
|
+
disabled={item.disabled}
|
|
50
|
+
onSelect={item.onSelect}
|
|
51
|
+
>
|
|
52
|
+
<span>{item.label}</span>
|
|
53
|
+
<Show when={item.shortcut}>
|
|
54
|
+
<span class="zylem-dropdown-shortcut">{item.shortcut}</span>
|
|
55
|
+
</Show>
|
|
56
|
+
</KDropdownMenu.Item>
|
|
57
|
+
</>
|
|
58
|
+
)}
|
|
59
|
+
</For>
|
|
60
|
+
);
|
|
61
|
+
|
|
29
62
|
return (
|
|
30
63
|
<KDropdownMenu.Root placement={props.placement ?? 'bottom-start'}>
|
|
31
|
-
<KDropdownMenu.Trigger class={props.triggerClass}>
|
|
64
|
+
<KDropdownMenu.Trigger ref={layer.anchorRef} class={props.triggerClass}>
|
|
32
65
|
{props.trigger}
|
|
33
66
|
</KDropdownMenu.Trigger>
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
disabled={item.disabled}
|
|
49
|
-
onSelect={item.onSelect}
|
|
50
|
-
>
|
|
51
|
-
<span>{item.label}</span>
|
|
52
|
-
<Show when={item.shortcut}>
|
|
53
|
-
<span class="zylem-dropdown-shortcut">{item.shortcut}</span>
|
|
54
|
-
</Show>
|
|
55
|
-
</KDropdownMenu.Item>
|
|
56
|
-
</>
|
|
57
|
-
)}
|
|
58
|
-
</For>
|
|
59
|
-
</KDropdownMenu.Content>
|
|
60
|
-
</KDropdownMenu.Portal>
|
|
67
|
+
<Show when={layer.mount()} keyed>
|
|
68
|
+
{(mount) => (
|
|
69
|
+
<KDropdownMenu.Portal mount={mount}>
|
|
70
|
+
<KDropdownMenu.Content
|
|
71
|
+
ref={dismiss.ref}
|
|
72
|
+
class="zylem-dropdown-content"
|
|
73
|
+
onInteractOutside={dismiss.onInteractOutside}
|
|
74
|
+
>
|
|
75
|
+
{items()}
|
|
76
|
+
</KDropdownMenu.Content>
|
|
77
|
+
</KDropdownMenu.Portal>
|
|
78
|
+
)}
|
|
79
|
+
</Show>
|
|
61
80
|
</KDropdownMenu.Root>
|
|
62
81
|
);
|
|
63
82
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { globalStyle } from '@vanilla-extract/css';
|
|
2
|
+
import { vars } from '../../theme.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Searchable, grouped picker list. Sized to sit inside a MenuButton panel, but
|
|
6
|
+
* width and height are capped rather than fixed so it also works as a sidebar
|
|
7
|
+
* or dialog section.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
globalStyle('.zylem-picker', {
|
|
11
|
+
display: 'flex',
|
|
12
|
+
flexDirection: 'column',
|
|
13
|
+
gap: vars.spacing.sm,
|
|
14
|
+
width: '260px',
|
|
15
|
+
maxHeight: '340px',
|
|
16
|
+
padding: vars.spacing.sm,
|
|
17
|
+
color: vars.colors.text,
|
|
18
|
+
fontFamily: vars.typography.fontFamily,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
globalStyle('.zylem-picker__search', {
|
|
22
|
+
flexShrink: 0,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
globalStyle('.zylem-picker__groups', {
|
|
26
|
+
display: 'flex',
|
|
27
|
+
flexDirection: 'column',
|
|
28
|
+
gap: vars.spacing.md,
|
|
29
|
+
overflowY: 'auto',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
globalStyle('.zylem-picker__group-title', {
|
|
33
|
+
margin: `0 0 ${vars.spacing.xs}`,
|
|
34
|
+
fontSize: '9px',
|
|
35
|
+
fontWeight: 600,
|
|
36
|
+
letterSpacing: '0.08em',
|
|
37
|
+
textTransform: 'uppercase',
|
|
38
|
+
opacity: 0.5,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
globalStyle('.zylem-picker__item', {
|
|
42
|
+
display: 'flex',
|
|
43
|
+
gap: vars.spacing.sm,
|
|
44
|
+
alignItems: 'center',
|
|
45
|
+
width: '100%',
|
|
46
|
+
padding: `${vars.spacing.xs} ${vars.spacing.xs}`,
|
|
47
|
+
fontFamily: 'inherit',
|
|
48
|
+
fontSize: `calc(${vars.typography.fontSize} - 2px)`,
|
|
49
|
+
color: 'inherit',
|
|
50
|
+
textAlign: 'left',
|
|
51
|
+
background: 'transparent',
|
|
52
|
+
border: '1px solid transparent',
|
|
53
|
+
borderRadius: vars.radii.control,
|
|
54
|
+
cursor: 'pointer',
|
|
55
|
+
transition: `background ${vars.motion.fast} ${vars.motion.easeOut}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
globalStyle('.zylem-picker__item:hover:not([disabled])', {
|
|
59
|
+
background: 'rgba(255, 255, 255, 0.08)',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
globalStyle('.zylem-picker__item:focus-visible', {
|
|
63
|
+
outline: 'none',
|
|
64
|
+
boxShadow: vars.effects.focusRing,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
globalStyle('.zylem-picker__item[data-selected]', {
|
|
68
|
+
background: 'rgba(120, 180, 255, 0.16)',
|
|
69
|
+
borderColor: 'rgba(120, 180, 255, 0.45)',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
globalStyle('.zylem-picker__item[disabled]', {
|
|
73
|
+
opacity: 0.45,
|
|
74
|
+
cursor: 'not-allowed',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
globalStyle('.zylem-picker__item-glyph', {
|
|
78
|
+
display: 'inline-flex',
|
|
79
|
+
flexShrink: 0,
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
color: 'currentColor',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
globalStyle('.zylem-picker__item-glyph svg', {
|
|
86
|
+
width: vars.sizes.iconSm,
|
|
87
|
+
height: vars.sizes.iconSm,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
globalStyle('.zylem-picker__item-label', {
|
|
91
|
+
overflow: 'hidden',
|
|
92
|
+
textOverflow: 'ellipsis',
|
|
93
|
+
whiteSpace: 'nowrap',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
globalStyle('.zylem-picker__empty', {
|
|
97
|
+
margin: 0,
|
|
98
|
+
padding: `${vars.spacing.sm} ${vars.spacing.xs}`,
|
|
99
|
+
fontSize: `calc(${vars.typography.fontSize} - 3px)`,
|
|
100
|
+
opacity: 0.6,
|
|
101
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { For, Show, createMemo, createSignal, type JSX } from 'solid-js';
|
|
2
|
+
import { SearchInput } from '../SearchInput/SearchInput';
|
|
3
|
+
import { filterItems, groupItems, type PickerItem } from './filter-items';
|
|
4
|
+
|
|
5
|
+
export interface ItemPickerProps<T extends PickerItem = PickerItem> {
|
|
6
|
+
items: T[];
|
|
7
|
+
onSelect: (item: T) => void;
|
|
8
|
+
/** Marked as the current choice. */
|
|
9
|
+
selectedId?: string | null | undefined;
|
|
10
|
+
/** Hide the search field for a list short enough not to need one. */
|
|
11
|
+
searchable?: boolean | undefined;
|
|
12
|
+
searchPlaceholder?: string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Focus the search field on mount. On by default: the picker is opened
|
|
15
|
+
* deliberately, so typing straight into it is the expected behaviour.
|
|
16
|
+
*/
|
|
17
|
+
autoFocus?: boolean | undefined;
|
|
18
|
+
/** Shown when `items` is empty, as opposed to filtered down to nothing. */
|
|
19
|
+
emptyMessage?: JSX.Element | undefined;
|
|
20
|
+
class?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Searchable, grouped list of choices — the picker half of a MenuButton, and
|
|
25
|
+
* usable on its own.
|
|
26
|
+
*
|
|
27
|
+
* Icons arrive as markup strings rather than components because the common case
|
|
28
|
+
* is a list supplied at runtime by something that does not share an icon library
|
|
29
|
+
* with the UI (the editor's entity catalog comes over a bridge from the running
|
|
30
|
+
* game, for instance).
|
|
31
|
+
*/
|
|
32
|
+
export function ItemPicker<T extends PickerItem>(props: ItemPickerProps<T>) {
|
|
33
|
+
const [query, setQuery] = createSignal('');
|
|
34
|
+
|
|
35
|
+
const searchable = () => props.searchable !== false;
|
|
36
|
+
const groups = createMemo(() => groupItems(filterItems(props.items, query())));
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div class={props.class ? `zylem-picker ${props.class}` : 'zylem-picker'}>
|
|
40
|
+
<Show when={searchable()}>
|
|
41
|
+
<SearchInput
|
|
42
|
+
class="zylem-picker__search"
|
|
43
|
+
value={query()}
|
|
44
|
+
onInput={setQuery}
|
|
45
|
+
placeholder={props.searchPlaceholder ?? 'Search…'}
|
|
46
|
+
ref={(element) => {
|
|
47
|
+
if (props.autoFocus === false) return;
|
|
48
|
+
// Deferred: the element is not focusable until it is in the document,
|
|
49
|
+
// which for a portaled panel is after this ref runs.
|
|
50
|
+
queueMicrotask(() => element.focus());
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
</Show>
|
|
54
|
+
|
|
55
|
+
<Show
|
|
56
|
+
when={props.items.length}
|
|
57
|
+
fallback={
|
|
58
|
+
<p class="zylem-picker__empty">
|
|
59
|
+
{props.emptyMessage ?? 'Nothing to choose from.'}
|
|
60
|
+
</p>
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
<Show
|
|
64
|
+
when={groups().length}
|
|
65
|
+
fallback={
|
|
66
|
+
<p class="zylem-picker__empty">No matches for “{query()}”.</p>
|
|
67
|
+
}
|
|
68
|
+
>
|
|
69
|
+
<div class="zylem-picker__groups zylem-scroll">
|
|
70
|
+
<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
|
+
)}
|
|
102
|
+
</For>
|
|
103
|
+
</div>
|
|
104
|
+
</Show>
|
|
105
|
+
</Show>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search and grouping for {@link ItemPicker}, kept pure and separate so the
|
|
3
|
+
* matching rules can be tested and reused without rendering anything.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Optional fields accept an explicit `undefined` so callers compiled with
|
|
8
|
+
* `exactOptionalPropertyTypes` can map their own optional data straight across
|
|
9
|
+
* without narrowing every field first.
|
|
10
|
+
*/
|
|
11
|
+
export interface PickerItem {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
/** Heading this item is filed under. Ungrouped items fall under `Other`. */
|
|
15
|
+
group?: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Leading glyph. A string is treated as inline SVG or HTML markup, which is
|
|
18
|
+
* what a host that does not share an icon library can realistically supply.
|
|
19
|
+
*/
|
|
20
|
+
icon?: string | undefined;
|
|
21
|
+
/** Shown as the row's title attribute. */
|
|
22
|
+
description?: string | undefined;
|
|
23
|
+
/** Extra search terms that are not visible on the row. */
|
|
24
|
+
tags?: string[] | undefined;
|
|
25
|
+
disabled?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PickerGroup<T extends PickerItem = PickerItem> {
|
|
29
|
+
group: string;
|
|
30
|
+
items: T[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Heading for items that declare no group. */
|
|
34
|
+
export const UNGROUPED_LABEL = 'Other';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Filter by a query matched against label, id, and tags. An empty or
|
|
38
|
+
* whitespace-only query returns everything, so an unopened search box is not a
|
|
39
|
+
* filter.
|
|
40
|
+
*/
|
|
41
|
+
export function filterItems<T extends PickerItem>(items: T[], query: string): T[] {
|
|
42
|
+
const needle = query.trim().toLowerCase();
|
|
43
|
+
if (!needle) return items;
|
|
44
|
+
|
|
45
|
+
return items.filter((item) => {
|
|
46
|
+
if (item.label.toLowerCase().includes(needle)) return true;
|
|
47
|
+
if (item.id.toLowerCase().includes(needle)) return true;
|
|
48
|
+
return item.tags?.some((tag) => tag.toLowerCase().includes(needle)) ?? false;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bucket items under their group.
|
|
54
|
+
*
|
|
55
|
+
* Order comes from the input rather than the alphabet, for both the groups and
|
|
56
|
+
* the items within them: the caller has already decided what should be near the
|
|
57
|
+
* top, and sorting here would throw that away.
|
|
58
|
+
*/
|
|
59
|
+
export function groupItems<T extends PickerItem>(items: T[]): PickerGroup<T>[] {
|
|
60
|
+
const groups: PickerGroup<T>[] = [];
|
|
61
|
+
const byName = new Map<string, T[]>();
|
|
62
|
+
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const name = item.group ?? UNGROUPED_LABEL;
|
|
65
|
+
let bucket = byName.get(name);
|
|
66
|
+
if (!bucket) {
|
|
67
|
+
bucket = [];
|
|
68
|
+
byName.set(name, bucket);
|
|
69
|
+
groups.push({ group: name, items: bucket });
|
|
70
|
+
}
|
|
71
|
+
bucket.push(item);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return groups;
|
|
75
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { globalStyle } from '@vanilla-extract/css';
|
|
2
|
+
import { vars } from '../../theme.css';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Split button with an attached menu. The face reuses `.zylem-toolbar-btn` for
|
|
6
|
+
* its look, so a MenuButton dropped into a toolbar matches its neighbours.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
globalStyle('.zylem-menu-button', {
|
|
10
|
+
position: 'relative',
|
|
11
|
+
display: 'inline-flex',
|
|
12
|
+
alignItems: 'stretch',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
globalStyle('.zylem-menu-button__face', {
|
|
16
|
+
display: 'inline-flex',
|
|
17
|
+
alignItems: 'center',
|
|
18
|
+
justifyContent: 'center',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* The chevron overlaps the face's trailing corner rather than sitting beside it,
|
|
23
|
+
* so gaining a second affordance does not widen the button and break a toolbar's
|
|
24
|
+
* rhythm.
|
|
25
|
+
*/
|
|
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}`,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* The non-split chevron is decoration over a face that is already the trigger.
|
|
48
|
+
* Ignoring the pointer keeps it from hovering or hit-testing like its own
|
|
49
|
+
* control, and lets clicks through to the face beneath.
|
|
50
|
+
*/
|
|
51
|
+
globalStyle('.zylem-menu-button__chevron--static', {
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
globalStyle('.zylem-menu-button__chevron:hover:not([disabled])', {
|
|
56
|
+
color: '#fff',
|
|
57
|
+
borderColor: 'rgba(255, 255, 255, 0.4)',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
globalStyle('.zylem-menu-button__chevron:focus-visible', {
|
|
61
|
+
outline: 'none',
|
|
62
|
+
boxShadow: vars.effects.focusRing,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
globalStyle('.zylem-menu-button__chevron[disabled]', {
|
|
66
|
+
opacity: 0.45,
|
|
67
|
+
cursor: 'not-allowed',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
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
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** Icon glyphs supplied as inline SVG markup rather than components. */
|
|
88
|
+
globalStyle('.zylem-menu-button__face svg', {
|
|
89
|
+
width: '18px',
|
|
90
|
+
height: '18px',
|
|
91
|
+
});
|