@moises.ai/design-system 4.15.11 → 4.16.0
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/index.js +3388 -3292
- package/package.json +1 -1
- package/src/components/InstrumentSelect/InstrumentSelect.jsx +162 -0
- package/src/components/InstrumentSelect/InstrumentSelect.module.css +146 -0
- package/src/components/InstrumentSelect/InstrumentSelect.stories.jsx +243 -0
- package/src/components/InstrumentSelect/InstrumentSelectCard.jsx +85 -0
- package/src/components/InstrumentSelect/index.js +2 -0
- package/src/components/InstrumentSelector/InstrumentSelector.jsx +8 -0
- package/src/components/Tooltip/Tooltip.module.css +3 -1
- package/src/index.jsx +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { forwardRef, useMemo } from 'react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import { Text } from '../Text/Text'
|
|
4
|
+
import { Tooltip } from '../Tooltip/Tooltip'
|
|
5
|
+
import { InfoIcon } from '../../icons'
|
|
6
|
+
import { InstrumentSelectCard } from './InstrumentSelectCard'
|
|
7
|
+
import styles from './InstrumentSelect.module.css'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} InstrumentSelectItem
|
|
11
|
+
* @property {string} value - Unique identifier for the item.
|
|
12
|
+
* @property {React.ReactNode} [text] - Item label (alias: `label`, `name`).
|
|
13
|
+
* @property {React.ReactNode} [label] - Alias for `text`.
|
|
14
|
+
* @property {React.ReactNode} [name] - Alias for `text`.
|
|
15
|
+
* @property {React.ComponentType<{ width?: number, height?: number }> | React.ReactNode} [Icon] - Icon component or pre-rendered JSX (alias: `icon`).
|
|
16
|
+
* @property {React.ComponentType<{ width?: number, height?: number }> | React.ReactNode} [icon] - Alias for `Icon`.
|
|
17
|
+
* @property {boolean} [locked] - Marks the item as paywalled (alias: `isLocked`). The lock icon is rendered. Click still fires `onSelect`.
|
|
18
|
+
* @property {boolean} [isLocked] - Alias for `locked`.
|
|
19
|
+
* @property {boolean} [disabled] - Disables the item without showing the lock icon.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {string | string[]} InstrumentSelectValue
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} InstrumentSelectProps
|
|
28
|
+
* @property {string} [title] - Optional header title rendered above the grid.
|
|
29
|
+
* @property {React.ReactNode} [tooltip] - Optional tooltip content. Renders an `InfoIcon` with a `Tooltip` next to the title.
|
|
30
|
+
* @property {InstrumentSelectItem[]} items - Items to render as cards.
|
|
31
|
+
* @property {InstrumentSelectValue} [value] - Currently selected value(s). Use a string for single-select and a string[] for multi-select. The component is fully controlled — the parent owns selection state and decides toggle/replace/paywall behavior in `onSelect`.
|
|
32
|
+
* @property {InstrumentSelectValue} [selectedValue] - Alias for `value` (backwards-compat with `InstrumentSelector`).
|
|
33
|
+
* @property {(item: InstrumentSelectItem) => void} [onSelect] - Fires for every card click, including locked items. The parent decides whether to update `value`, open a paywall, etc.
|
|
34
|
+
* @property {number | { initial?: string | number, sm?: string | number, md?: string | number, lg?: string | number }} [columns=2] - Number of grid columns. Accepts a number or a Radix-style responsive object (only `initial` is honored).
|
|
35
|
+
* @property {string} [className]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Computes whether a given item value is selected, supporting both string (single) and
|
|
40
|
+
* string[] (multi) value shapes.
|
|
41
|
+
*
|
|
42
|
+
* @param {InstrumentSelectValue | undefined} value
|
|
43
|
+
* @param {string} itemValue
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function isItemSelected(value, itemValue) {
|
|
47
|
+
if (value == null) return false
|
|
48
|
+
if (Array.isArray(value)) return value.includes(itemValue)
|
|
49
|
+
return value === itemValue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Instrument selector list with optional title and tooltip header.
|
|
54
|
+
*
|
|
55
|
+
* The component is purely presentational — it does NOT track selection state, does NOT
|
|
56
|
+
* toggle/replace values, and does NOT handle paywalls. The parent owns all of that:
|
|
57
|
+
*
|
|
58
|
+
* - Pass `value` to mark items as selected (string for single, string[] for multi).
|
|
59
|
+
* - `onSelect(item)` fires for every click; the parent decides what to do.
|
|
60
|
+
*
|
|
61
|
+
* Layout: 2-column responsive grid by default with 4px gap. Cards have `min-width: 150px`
|
|
62
|
+
* and the columns adapt to the parent's width.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* // Single select
|
|
66
|
+
* const [value, setValue] = useState('drums')
|
|
67
|
+
* <InstrumentSelect
|
|
68
|
+
* items={INSTRUMENTS}
|
|
69
|
+
* value={value}
|
|
70
|
+
* onSelect={(item) => setValue(item.value)}
|
|
71
|
+
* />
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* // Multi select with paywall — parent owns toggle + paywall logic
|
|
75
|
+
* const [stems, setStems] = useState([])
|
|
76
|
+
* <InstrumentSelect
|
|
77
|
+
* title="Music Stems"
|
|
78
|
+
* tooltip="Select the stems to separate"
|
|
79
|
+
* items={STEMS}
|
|
80
|
+
* value={stems}
|
|
81
|
+
* onSelect={(item) => {
|
|
82
|
+
* if (item.locked) return openPaywall(item)
|
|
83
|
+
* setStems((curr) => curr.includes(item.value)
|
|
84
|
+
* ? curr.filter((v) => v !== item.value)
|
|
85
|
+
* : [...curr, item.value])
|
|
86
|
+
* }}
|
|
87
|
+
* />
|
|
88
|
+
*
|
|
89
|
+
* @type {React.ForwardRefExoticComponent<InstrumentSelectProps & React.RefAttributes<HTMLDivElement>>}
|
|
90
|
+
*/
|
|
91
|
+
export const InstrumentSelect = forwardRef(function InstrumentSelect(
|
|
92
|
+
{
|
|
93
|
+
title,
|
|
94
|
+
tooltip,
|
|
95
|
+
items = [],
|
|
96
|
+
value,
|
|
97
|
+
selectedValue,
|
|
98
|
+
onSelect,
|
|
99
|
+
columns = 2,
|
|
100
|
+
className,
|
|
101
|
+
...props
|
|
102
|
+
},
|
|
103
|
+
ref,
|
|
104
|
+
) {
|
|
105
|
+
const resolvedValue = value ?? selectedValue
|
|
106
|
+
|
|
107
|
+
const cols = useMemo(() => {
|
|
108
|
+
if (typeof columns === 'number') return columns
|
|
109
|
+
if (columns && typeof columns === 'object') {
|
|
110
|
+
const initial = columns.initial
|
|
111
|
+
const parsed = typeof initial === 'string' ? parseInt(initial, 10) : initial
|
|
112
|
+
return Number.isFinite(parsed) ? parsed : 2
|
|
113
|
+
}
|
|
114
|
+
return 2
|
|
115
|
+
}, [columns])
|
|
116
|
+
|
|
117
|
+
const gridStyle = useMemo(
|
|
118
|
+
() => ({ gridTemplateColumns: `repeat(${cols}, minmax(150px, 1fr))` }),
|
|
119
|
+
[cols],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div ref={ref} className={classNames(styles.root, className)} {...props}>
|
|
124
|
+
{title && (
|
|
125
|
+
<div className={styles.header}>
|
|
126
|
+
<Text size="2" weight="medium" className={styles.title}>
|
|
127
|
+
{title}
|
|
128
|
+
</Text>
|
|
129
|
+
{tooltip && (
|
|
130
|
+
<Tooltip content={tooltip}>
|
|
131
|
+
<InfoIcon width={16} height={16} className={styles.infoIcon} />
|
|
132
|
+
</Tooltip>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<div className={styles.grid} style={gridStyle} role="group" aria-label={title}>
|
|
138
|
+
{items.map((item) => {
|
|
139
|
+
const itemText = item.text ?? item.label ?? item.name
|
|
140
|
+
const ItemIcon = item.Icon ?? item.icon
|
|
141
|
+
const itemLocked = item.locked ?? item.isLocked ?? false
|
|
142
|
+
const itemDisabled = item.disabled ?? false
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<InstrumentSelectCard
|
|
146
|
+
key={item.value}
|
|
147
|
+
value={item.value}
|
|
148
|
+
text={itemText}
|
|
149
|
+
Icon={ItemIcon}
|
|
150
|
+
selected={isItemSelected(resolvedValue, item.value)}
|
|
151
|
+
locked={itemLocked}
|
|
152
|
+
disabled={itemDisabled}
|
|
153
|
+
onClick={() => onSelect?.(item)}
|
|
154
|
+
/>
|
|
155
|
+
)
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
InstrumentSelect.displayName = 'InstrumentSelect'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/* ----- Root + Header ----- */
|
|
2
|
+
|
|
3
|
+
.root {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--space-2, 8px);
|
|
7
|
+
width: 100%;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: var(--space-2, 8px);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.title {
|
|
17
|
+
color: var(--neutral-alpha-12);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.infoIcon {
|
|
21
|
+
flex-shrink: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.infoIcon path {
|
|
25
|
+
fill: var(--neutral-alpha-7);
|
|
26
|
+
transition: fill 0.18s ease;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.infoIcon:hover path {
|
|
30
|
+
fill: var(--neutral-alpha-9);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ----- Grid ----- */
|
|
34
|
+
|
|
35
|
+
.grid {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
|
38
|
+
gap: 4px;
|
|
39
|
+
width: 100%;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ----- Card ----- */
|
|
43
|
+
|
|
44
|
+
.card {
|
|
45
|
+
position: relative;
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: var(--space-3, 12px);
|
|
49
|
+
min-width: 150px;
|
|
50
|
+
padding: 10px 8px 10px 12px;
|
|
51
|
+
border: 1px solid var(--neutral-alpha-4, rgba(211, 237, 248, 0.11));
|
|
52
|
+
border-radius: var(--radius-3, 6px);
|
|
53
|
+
background: transparent;
|
|
54
|
+
color: var(--neutral-alpha-12);
|
|
55
|
+
font-family: inherit;
|
|
56
|
+
font-size: 14px;
|
|
57
|
+
line-height: 20px;
|
|
58
|
+
text-align: left;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
user-select: none;
|
|
61
|
+
box-sizing: border-box;
|
|
62
|
+
transition:
|
|
63
|
+
background-color 0.18s ease,
|
|
64
|
+
border-color 0.18s ease,
|
|
65
|
+
color 0.18s ease;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.card:focus-visible {
|
|
69
|
+
outline: 2px solid var(--neutral-alpha-8);
|
|
70
|
+
outline-offset: 2px;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* hover (native + forced via data-state='hover' for documentation/Storybook) */
|
|
74
|
+
.card[data-state='default']:hover,
|
|
75
|
+
.card[data-state='hover'] {
|
|
76
|
+
border-color: var(--neutral-alpha-6, rgba(214, 235, 253, 0.19));
|
|
77
|
+
background: var(--neutral-alpha-2, rgba(216, 244, 246, 0.04));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* checked */
|
|
81
|
+
.card[data-state='checked'] {
|
|
82
|
+
border-color: var(--aqua-9, #00dae8);
|
|
83
|
+
background: var(--neutral-alpha-2, rgba(216, 244, 246, 0.04));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* disabled / locked — same border/background as default; text + lock are muted, icon stays */
|
|
87
|
+
.card[data-state='disabled'] {
|
|
88
|
+
cursor: default;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.card[data-state='disabled'] .cardText,
|
|
92
|
+
.card[data-state='disabled'] .lockIcon {
|
|
93
|
+
color: var(--neutral-alpha-5);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ----- Card slots ----- */
|
|
97
|
+
|
|
98
|
+
.cardIcon {
|
|
99
|
+
display: inline-flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: center;
|
|
102
|
+
flex-shrink: 0;
|
|
103
|
+
width: 16px;
|
|
104
|
+
height: 16px;
|
|
105
|
+
color: var(--neutral-alpha-7);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.cardIcon svg {
|
|
109
|
+
width: 16px;
|
|
110
|
+
height: 16px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.card[data-state='checked'] .cardIcon {
|
|
114
|
+
color: var(--aqua-9, #00dae8);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.cardText {
|
|
118
|
+
flex: 1 1 auto;
|
|
119
|
+
min-width: 0;
|
|
120
|
+
font-weight: 400;
|
|
121
|
+
letter-spacing: 0;
|
|
122
|
+
color: var(--neutral-alpha-12);
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
text-overflow: ellipsis;
|
|
125
|
+
white-space: nowrap;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* When the lock is present, reserve space for it so the text truncates with ellipsis
|
|
130
|
+
* BEFORE reaching the lock icon. Lock geometry: width 12px + right offset 11px from the
|
|
131
|
+
* card edge - existing card padding-right 8px = ~16px buffer needed (with 1px breathing room).
|
|
132
|
+
*/
|
|
133
|
+
.card[data-locked] .cardText {
|
|
134
|
+
margin-right: 16px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.lockIcon {
|
|
138
|
+
position: absolute;
|
|
139
|
+
top: 50%;
|
|
140
|
+
right: 11px;
|
|
141
|
+
width: 12px;
|
|
142
|
+
height: 12px;
|
|
143
|
+
transform: translateY(-50%);
|
|
144
|
+
color: var(--neutral-alpha-5);
|
|
145
|
+
pointer-events: none;
|
|
146
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { InstrumentSelect } from './InstrumentSelect'
|
|
3
|
+
import { InstrumentSelectCard } from './InstrumentSelectCard'
|
|
4
|
+
import {
|
|
5
|
+
AcousticGuitarIcon,
|
|
6
|
+
BassIcon,
|
|
7
|
+
DrumsIcon,
|
|
8
|
+
ElectricGuitarIcon,
|
|
9
|
+
KeysIcon,
|
|
10
|
+
PianoIcon,
|
|
11
|
+
StringsIcon,
|
|
12
|
+
VocalIcon,
|
|
13
|
+
VocalsBackgroundIcon,
|
|
14
|
+
WindIcon,
|
|
15
|
+
} from '../../icons'
|
|
16
|
+
|
|
17
|
+
const INSTRUMENTS = [
|
|
18
|
+
{ value: 'vocals', text: 'Vocals', Icon: VocalIcon },
|
|
19
|
+
{ value: 'lead-vocals', text: 'Lead Vocals', Icon: VocalsBackgroundIcon },
|
|
20
|
+
{ value: 'guitars', text: 'Guitars', Icon: ElectricGuitarIcon },
|
|
21
|
+
{ value: 'acoustic-guitar', text: 'Acoustic Guitar', Icon: AcousticGuitarIcon },
|
|
22
|
+
{ value: 'bass', text: 'Bass', Icon: BassIcon },
|
|
23
|
+
{ value: 'piano', text: 'Piano', Icon: PianoIcon },
|
|
24
|
+
{ value: 'keys', text: 'Keys', Icon: KeysIcon },
|
|
25
|
+
{ value: 'strings', text: 'Strings', Icon: StringsIcon },
|
|
26
|
+
{ value: 'drums', text: 'Drums', Icon: DrumsIcon },
|
|
27
|
+
{ value: 'wind', text: 'Wind', Icon: WindIcon },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const STEMS_WITH_LOCKED = [
|
|
31
|
+
{ value: 'vocals', text: 'Vocals', Icon: VocalIcon },
|
|
32
|
+
{ value: 'lead-vocals', text: 'Lead Vocals', Icon: VocalsBackgroundIcon, locked: true },
|
|
33
|
+
{ value: 'guitars', text: 'Guitars', Icon: ElectricGuitarIcon },
|
|
34
|
+
{ value: 'rhythm-guitars', text: 'Rhythm Guitars', Icon: ElectricGuitarIcon, locked: true },
|
|
35
|
+
{ value: 'bass', text: 'Bass', Icon: BassIcon },
|
|
36
|
+
{ value: 'solo-guitars', text: 'Solo Guitars', Icon: ElectricGuitarIcon, locked: true },
|
|
37
|
+
{ value: 'piano', text: 'Piano', Icon: PianoIcon },
|
|
38
|
+
{ value: 'keys', text: 'Keys', Icon: KeysIcon },
|
|
39
|
+
{ value: 'drums', text: 'Drums', Icon: DrumsIcon },
|
|
40
|
+
{ value: 'wind', text: 'Wind', Icon: WindIcon, locked: true },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
title: 'Components/InstrumentSelect',
|
|
45
|
+
component: InstrumentSelect,
|
|
46
|
+
parameters: {
|
|
47
|
+
layout: 'centered',
|
|
48
|
+
},
|
|
49
|
+
tags: ['autodocs'],
|
|
50
|
+
argTypes: {
|
|
51
|
+
columns: { control: { type: 'number', min: 1, max: 4 } },
|
|
52
|
+
title: { control: 'text' },
|
|
53
|
+
tooltip: { control: 'text' },
|
|
54
|
+
},
|
|
55
|
+
decorators: [
|
|
56
|
+
(Story) => (
|
|
57
|
+
<div style={{ width: 340 }}>
|
|
58
|
+
<Story />
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ---------- Single Select ---------- */
|
|
65
|
+
|
|
66
|
+
export const SingleSelect = {
|
|
67
|
+
render: (args) => {
|
|
68
|
+
const [value, setValue] = useState('drums')
|
|
69
|
+
return (
|
|
70
|
+
<InstrumentSelect
|
|
71
|
+
{...args}
|
|
72
|
+
value={value}
|
|
73
|
+
onSelect={(item) => setValue(item.value)}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
args: {
|
|
78
|
+
items: INSTRUMENTS.slice(0, 6),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---------- Multi Select ---------- */
|
|
83
|
+
|
|
84
|
+
export const MultiSelect = {
|
|
85
|
+
render: (args) => {
|
|
86
|
+
const [values, setValues] = useState(['vocals', 'guitars'])
|
|
87
|
+
return (
|
|
88
|
+
<InstrumentSelect
|
|
89
|
+
{...args}
|
|
90
|
+
value={values}
|
|
91
|
+
onSelect={(item) => {
|
|
92
|
+
setValues((curr) =>
|
|
93
|
+
curr.includes(item.value)
|
|
94
|
+
? curr.filter((v) => v !== item.value)
|
|
95
|
+
: [...curr, item.value],
|
|
96
|
+
)
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
},
|
|
101
|
+
args: {
|
|
102
|
+
items: INSTRUMENTS.slice(0, 6),
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ---------- With Title ---------- */
|
|
107
|
+
|
|
108
|
+
export const WithTitle = {
|
|
109
|
+
render: (args) => {
|
|
110
|
+
const [value, setValue] = useState('drums')
|
|
111
|
+
return (
|
|
112
|
+
<InstrumentSelect
|
|
113
|
+
{...args}
|
|
114
|
+
value={value}
|
|
115
|
+
onSelect={(item) => setValue(item.value)}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
},
|
|
119
|
+
args: {
|
|
120
|
+
title: 'Music Stems',
|
|
121
|
+
items: INSTRUMENTS.slice(0, 6),
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ---------- With Title And Tooltip ---------- */
|
|
126
|
+
|
|
127
|
+
export const WithTitleAndTooltip = {
|
|
128
|
+
render: (args) => {
|
|
129
|
+
const [value, setValue] = useState('drums')
|
|
130
|
+
return (
|
|
131
|
+
<InstrumentSelect
|
|
132
|
+
{...args}
|
|
133
|
+
value={value}
|
|
134
|
+
onSelect={(item) => setValue(item.value)}
|
|
135
|
+
/>
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
args: {
|
|
139
|
+
title: 'Music Stems',
|
|
140
|
+
tooltip: 'Choose the instrument that best matches what you want to generate.',
|
|
141
|
+
items: INSTRUMENTS.slice(0, 6),
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ---------- With Locked Items (multi-select with paywall) ---------- */
|
|
146
|
+
|
|
147
|
+
export const WithLockedItems = {
|
|
148
|
+
render: (args) => {
|
|
149
|
+
const [values, setValues] = useState(['vocals', 'guitars'])
|
|
150
|
+
return (
|
|
151
|
+
<InstrumentSelect
|
|
152
|
+
{...args}
|
|
153
|
+
value={values}
|
|
154
|
+
onSelect={(item) => {
|
|
155
|
+
if (item.locked) {
|
|
156
|
+
// eslint-disable-next-line no-alert
|
|
157
|
+
alert(`Open paywall for: ${item.text}`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
setValues((curr) =>
|
|
161
|
+
curr.includes(item.value)
|
|
162
|
+
? curr.filter((v) => v !== item.value)
|
|
163
|
+
: [...curr, item.value],
|
|
164
|
+
)
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
},
|
|
169
|
+
args: {
|
|
170
|
+
title: 'Music Stems',
|
|
171
|
+
tooltip: 'Some stems are part of the Premium plan.',
|
|
172
|
+
items: STEMS_WITH_LOCKED,
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ---------- Card states (forced) ---------- */
|
|
177
|
+
|
|
178
|
+
export const CardStates = () => (
|
|
179
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 162px)', gap: 4 }}>
|
|
180
|
+
<InstrumentSelectCard value="vocals" text="Default" Icon={VocalIcon} state="default" />
|
|
181
|
+
<InstrumentSelectCard value="vocals" text="Hover" Icon={VocalIcon} state="hover" />
|
|
182
|
+
<InstrumentSelectCard value="vocals" text="Checked" Icon={VocalIcon} state="checked" />
|
|
183
|
+
<InstrumentSelectCard
|
|
184
|
+
value="vocals"
|
|
185
|
+
text="Disabled"
|
|
186
|
+
Icon={VocalIcon}
|
|
187
|
+
state="disabled"
|
|
188
|
+
locked
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
CardStates.parameters = {
|
|
194
|
+
docs: {
|
|
195
|
+
description: {
|
|
196
|
+
story:
|
|
197
|
+
'The four visual states of `InstrumentSelectCard`: default, hover, checked, and disabled (locked).',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ---------- Columns ---------- */
|
|
203
|
+
|
|
204
|
+
export const OneColumn = {
|
|
205
|
+
render: (args) => {
|
|
206
|
+
const [value, setValue] = useState('drums')
|
|
207
|
+
return (
|
|
208
|
+
<InstrumentSelect
|
|
209
|
+
{...args}
|
|
210
|
+
value={value}
|
|
211
|
+
onSelect={(item) => setValue(item.value)}
|
|
212
|
+
/>
|
|
213
|
+
)
|
|
214
|
+
},
|
|
215
|
+
args: {
|
|
216
|
+
columns: 1,
|
|
217
|
+
items: INSTRUMENTS.slice(0, 6),
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export const ThreeColumns = {
|
|
222
|
+
render: (args) => {
|
|
223
|
+
const [value, setValue] = useState('drums')
|
|
224
|
+
return (
|
|
225
|
+
<InstrumentSelect
|
|
226
|
+
{...args}
|
|
227
|
+
value={value}
|
|
228
|
+
onSelect={(item) => setValue(item.value)}
|
|
229
|
+
/>
|
|
230
|
+
)
|
|
231
|
+
},
|
|
232
|
+
args: {
|
|
233
|
+
columns: 3,
|
|
234
|
+
items: INSTRUMENTS,
|
|
235
|
+
},
|
|
236
|
+
decorators: [
|
|
237
|
+
(Story) => (
|
|
238
|
+
<div style={{ width: 540 }}>
|
|
239
|
+
<Story />
|
|
240
|
+
</div>
|
|
241
|
+
),
|
|
242
|
+
],
|
|
243
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import classNames from 'classnames'
|
|
3
|
+
import { LockIcon } from '../../icons'
|
|
4
|
+
import styles from './InstrumentSelect.module.css'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {'default' | 'hover' | 'checked' | 'disabled'} InstrumentSelectCardState
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} InstrumentSelectCardProps
|
|
12
|
+
* @property {string} value - Card value identifier (used by parent list for selection).
|
|
13
|
+
* @property {React.ReactNode} [text] - Card label text.
|
|
14
|
+
* @property {React.ComponentType<{ width?: number, height?: number }> | React.ReactNode} [Icon] - Icon as a component or pre-rendered JSX element.
|
|
15
|
+
* @property {boolean} [selected=false] - Whether the card is currently selected. Drives the `checked` visual state.
|
|
16
|
+
* @property {boolean} [locked=false] - Marks the card as locked (paywalled). Renders the lock icon and applies the disabled visual state. `onClick` still fires so consumers can trigger a paywall flow.
|
|
17
|
+
* @property {boolean} [disabled=false] - Marks the card as disabled without showing the lock icon.
|
|
18
|
+
* @property {InstrumentSelectCardState} [state] - Force a specific visual state (overrides `selected`/`locked`/`disabled`). Primarily for documentation/Storybook.
|
|
19
|
+
* @property {(event: React.MouseEvent<HTMLButtonElement>) => void} [onClick]
|
|
20
|
+
* @property {string} [className]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Individual instrument card with four visual states (default, hover, checked, disabled).
|
|
25
|
+
*
|
|
26
|
+
* Native `<button>` with `aria-pressed` for selection (works for both single and multi-select)
|
|
27
|
+
* and `aria-disabled` for locked/disabled items. The HTML `disabled` attribute is intentionally
|
|
28
|
+
* NOT used so that locked items remain clickable (e.g. to trigger paywall flows).
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* <InstrumentSelectCard value="vocals" text="Vocals" Icon={VocalIcon} selected />
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* // Locked item (clickable, shows lock icon at the right edge)
|
|
35
|
+
* <InstrumentSelectCard value="hifi" text="HiFi" Icon={HiFiIcon} locked onClick={openPaywall} />
|
|
36
|
+
*
|
|
37
|
+
* @type {React.ForwardRefExoticComponent<InstrumentSelectCardProps & React.RefAttributes<HTMLButtonElement>>}
|
|
38
|
+
*/
|
|
39
|
+
export const InstrumentSelectCard = forwardRef(function InstrumentSelectCard(
|
|
40
|
+
{
|
|
41
|
+
value,
|
|
42
|
+
text,
|
|
43
|
+
Icon,
|
|
44
|
+
selected = false,
|
|
45
|
+
locked = false,
|
|
46
|
+
disabled = false,
|
|
47
|
+
state,
|
|
48
|
+
onClick,
|
|
49
|
+
className,
|
|
50
|
+
...props
|
|
51
|
+
},
|
|
52
|
+
ref,
|
|
53
|
+
) {
|
|
54
|
+
const isBlocked = locked || disabled
|
|
55
|
+
const computedState = state ?? (isBlocked ? 'disabled' : selected ? 'checked' : 'default')
|
|
56
|
+
|
|
57
|
+
const renderIcon = () => {
|
|
58
|
+
if (!Icon) return null
|
|
59
|
+
if (typeof Icon === 'function') {
|
|
60
|
+
return <Icon width={16} height={16} />
|
|
61
|
+
}
|
|
62
|
+
return Icon
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<button
|
|
67
|
+
ref={ref}
|
|
68
|
+
type="button"
|
|
69
|
+
data-state={computedState}
|
|
70
|
+
data-value={value}
|
|
71
|
+
data-locked={locked || undefined}
|
|
72
|
+
aria-pressed={selected}
|
|
73
|
+
aria-disabled={isBlocked || undefined}
|
|
74
|
+
onClick={onClick}
|
|
75
|
+
className={classNames(styles.card, className)}
|
|
76
|
+
{...props}
|
|
77
|
+
>
|
|
78
|
+
{Icon && <span className={styles.cardIcon}>{renderIcon()}</span>}
|
|
79
|
+
{text != null && <span className={styles.cardText}>{text}</span>}
|
|
80
|
+
{locked && <LockIcon width={12} height={12} className={styles.lockIcon} />}
|
|
81
|
+
</button>
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
InstrumentSelectCard.displayName = 'InstrumentSelectCard'
|
|
@@ -2,6 +2,14 @@ import { RadioCards as RadioCardsRadix, Flex, Text } from '@radix-ui/themes'
|
|
|
2
2
|
import styles from './InstrumentSelector.module.css'
|
|
3
3
|
import classNames from 'classnames'
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @deprecated Use `InstrumentSelect` instead. `InstrumentSelector` will be removed in a
|
|
7
|
+
* future release. The new component supports both single and multi-select (auto-inferred
|
|
8
|
+
* from the value type), exposes a `title` and `tooltip` header, and matches the updated
|
|
9
|
+
* Figma design (cyan border on selected). All existing props are accepted as aliases.
|
|
10
|
+
*
|
|
11
|
+
* @see InstrumentSelect
|
|
12
|
+
*/
|
|
5
13
|
export const InstrumentSelector = ({
|
|
6
14
|
items = [],
|
|
7
15
|
defaultValue,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
align-items: center;
|
|
4
4
|
gap: 2px;
|
|
5
5
|
padding: 6px 12px;
|
|
6
|
+
max-width: 232px;
|
|
6
7
|
text-align: center;
|
|
7
8
|
z-index: 9999;
|
|
8
9
|
animation-duration: 0.15s;
|
|
@@ -85,7 +86,8 @@
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
.TooltipText {
|
|
88
|
-
white-space:
|
|
89
|
+
white-space: normal;
|
|
90
|
+
overflow-wrap: anywhere;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
.TooltipShortcut {
|
package/src/index.jsx
CHANGED
|
@@ -102,6 +102,9 @@ export { HeaderPanel } from './components/HeaderPanel/HeaderPanel'
|
|
|
102
102
|
export { HorizontalVolume } from './components/HorizontalVolume/HorizontalVolume'
|
|
103
103
|
export { IconButton } from './components/IconButton/IconButton'
|
|
104
104
|
export { InputLevelMeter } from './components/InputLevelMeter/InputLevelMeter'
|
|
105
|
+
export { InstrumentSelect } from './components/InstrumentSelect/InstrumentSelect'
|
|
106
|
+
export { InstrumentSelectCard } from './components/InstrumentSelect/InstrumentSelectCard'
|
|
107
|
+
/** @deprecated Use `InstrumentSelect` instead. */
|
|
105
108
|
export { InstrumentSelector } from './components/InstrumentSelector/InstrumentSelector'
|
|
106
109
|
export { Knob } from './components/Knob/Knob'
|
|
107
110
|
export { ListCards } from './components/ListCards/ListCards'
|