@studiocms/ui 0.4.12 → 0.4.14

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.
@@ -14,6 +14,10 @@ interface Props extends Omit<HTMLAttributes<'span'>, 'color'> {
14
14
  * The icon to display in the badge.
15
15
  */
16
16
  icon?: HeroIconName;
17
+ /**
18
+ * The icon position. Defaults to `left`.
19
+ */
20
+ iconPosition?: 'left' | 'right';
17
21
  /**
18
22
  * The size of the badge. Defaults to `md`.
19
23
  */
@@ -38,12 +42,22 @@ const {
38
42
  size = 'md',
39
43
  variant = 'default',
40
44
  rounding = 'full',
45
+ iconPosition = 'left',
41
46
  label,
47
+ class: className,
42
48
  ...props
43
49
  } = Astro.props;
50
+
51
+ let iconSize = 16;
52
+ if (size === 'sm') {
53
+ iconSize = 8;
54
+ } else if (size === 'lg') {
55
+ iconSize = 24;
56
+ }
44
57
  ---
45
58
 
46
- <span class="sui-badge" class:list={[color, size, variant, rounding]} {...props}>
47
- {icon && <Icon name={icon} width={16} height={16} />}
59
+ <span class="sui-badge" class:list={[color, size, variant, rounding, className]} {...props}>
60
+ {icon && iconPosition === 'left' && <Icon name={icon} width={iconSize} height={iconSize} />}
48
61
  {label}
62
+ {icon && iconPosition === 'right' && <Icon name={icon} width={iconSize} height={iconSize} />}
49
63
  </span>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import { generateID } from '../../utils/generateID.js';
3
+ import Badge from '../Badge/Badge.astro';
3
4
  import Icon from '../Icon/Icon.astro';
4
5
  import Input from '../Input/Input.astro';
5
6
  import './searchselect.css';
@@ -25,45 +26,71 @@ interface Option {
25
26
  /**
26
27
  * The props for the search select component.
27
28
  */
28
- interface Props {
29
+ type NonMultipleProps = {
29
30
  /**
30
- * The label of the search select.
31
+ * Whether the select accepts multiple options. Defaults to `false`.
31
32
  */
32
- label?: string;
33
+ multiple: false;
34
+ /**
35
+ * The default value of the select.
36
+ */
37
+ defaultValue: string;
38
+ };
39
+
40
+ type MultipleProps = {
41
+ /**
42
+ * Whether the select accepts multiple options. Defaults to `false`.
43
+ */
44
+ multiple: true;
33
45
  /**
34
- * The default value of the search select. Needs to be one of the values in the options.
46
+ * The default value of the select.
35
47
  */
36
- defaultValue?: string;
48
+ defaultValue: string[];
49
+ };
50
+
51
+ /**
52
+ * The props for the select component.
53
+ */
54
+ type BaseProps = {
37
55
  /**
38
- * Additional classes to apply to the search select.
56
+ * The label of the select.
57
+ */
58
+ label?: string;
59
+ /**
60
+ * Additional classes to apply to the select.
39
61
  */
40
62
  class?: string;
41
63
  /**
42
- * The name of the search select.
64
+ * The name of the select. Required because of the helper.
43
65
  */
44
66
  name?: string;
45
67
  /**
46
- * Whether the search select is required. Defaults to `false`.
68
+ * Whether the select is required. Defaults to `false`.
47
69
  */
48
70
  isRequired?: boolean;
49
71
  /**
50
- * The options to display in the search select.
72
+ * The options to display in the select.
51
73
  */
52
74
  options: Option[];
53
75
  /**
54
- * Whether the search select is disabled. Defaults to `false`.
76
+ * Whether the select is disabled. Defaults to `false`.
55
77
  */
56
78
  disabled?: boolean;
57
79
  /**
58
- * Whether the search select should take up the full width of its container.
80
+ * Whether the select is full width. Defaults to `false`.
59
81
  */
60
82
  fullWidth?: boolean;
61
83
  /**
62
- * The placeholder of the search select.
84
+ * The placeholder of the select. Defaults to `Select`.
63
85
  */
64
86
  placeholder?: string;
65
- }
87
+ /**
88
+ * The maximum number of options that can be selected. Defaults to `undefined`.
89
+ */
90
+ max?: number;
91
+ };
66
92
 
93
+ type Props = BaseProps & (MultipleProps | NonMultipleProps);
67
94
  const {
68
95
  label,
69
96
  defaultValue,
@@ -73,62 +100,108 @@ const {
73
100
  options = [],
74
101
  disabled,
75
102
  fullWidth,
76
- placeholder,
103
+ placeholder = 'Select',
104
+ multiple = false,
105
+ max = undefined,
77
106
  } = Astro.props;
107
+
108
+ let selected: Option | (Option | undefined)[] | undefined;
109
+
110
+ if (multiple && Array.isArray(defaultValue)) {
111
+ selected = defaultValue.map((x) => options.find((y) => y.value === x));
112
+ } else {
113
+ selected = options.find((x) => x.value === defaultValue);
114
+ }
115
+
116
+ const defaultLabel = selected
117
+ ? Array.isArray(selected)
118
+ ? placeholder
119
+ : selected.label
120
+ : placeholder;
78
121
  ---
79
122
 
80
123
  <div
81
- id={`${name}-container`}
82
- class="sui-search-select-label"
83
- class:list={[disabled && "disabled", className, fullWidth && "full"]}
84
- data-options={JSON.stringify(options)}
85
- data-id={name}
124
+ id={`${name}-container`}
125
+ class="sui-search-select-label"
126
+ class:list={[disabled && "disabled", className, fullWidth && "full"]}
127
+ data-options={JSON.stringify(options)}
128
+ data-multiple={multiple ? "true" : undefined}
129
+ data-multiple-max={multiple && max !== undefined ? max : undefined}
130
+ data-id={name}
86
131
  >
87
- <div class="sui-search-input-wrapper" id={`${name}-search-input-wrapper`}>
88
- <Input
89
- placeholder={options.find((x) => x.value === defaultValue)?.label || (placeholder || "Select")}
90
- role='combobox'
91
- aria-controls={`${name}-dropdown`}
92
- aria-expanded="false"
93
- label={label || ''}
94
- isRequired={isRequired || false}
95
- />
96
- <Icon name="chevron-up-down" class="sui-search-select-indicator" width={24} height={24} />
97
- </div>
98
- <ul class="sui-search-select-dropdown" role="listbox" id={`${name}-dropdown`}>
99
- {
100
- options.map((x, i) => (
101
- <li
102
- class="sui-search-select-option"
103
- role="option"
104
- value={x.value}
105
- class:list={[
106
- x.disabled && "disabled",
107
- i === 0 && 'focused',
108
- ]}
109
- id={defaultValue === x.value ? `${name}-selected` : ""}
110
- data-option-index={i}
111
- data-value={x.value}
112
- >
113
- {x.label}
114
- </li>
115
- ))
116
- }
117
- </ul>
118
- <select class="sui-hidden-select" id={name} name={name} required={isRequired} hidden tabindex="-1">
119
- <option value={""}> Select </option>
120
- {
121
- options.map((x) => (
122
- <option
123
- value={x.value}
124
- selected={defaultValue === x.value}
125
- disabled={x.disabled}
126
- >
127
- {x.label}
128
- </option>
129
- ))
130
- }
131
- </select>
132
+ <div class="sui-search-select-dropdown-container">
133
+ <div class="sui-search-input-wrapper" id={`${name}-search-input-wrapper`}>
134
+ <Input
135
+ placeholder={defaultLabel}
136
+ role='combobox'
137
+ aria-controls={`${name}-dropdown`}
138
+ aria-expanded="false"
139
+ label={label || ''}
140
+ isRequired={isRequired || false}
141
+ />
142
+ <Icon name="chevron-up-down" class="sui-search-select-indicator" width={24} height={24} />
143
+ </div>
144
+ <ul class="sui-search-select-dropdown" role="listbox" id={`${name}-dropdown`}>
145
+ {
146
+ options.map((x, i) => {
147
+ const isSelected = Array.isArray(selected)
148
+ ? selected.map((y) => y && y.value).includes(x.value)
149
+ : selected?.value === x.value;
150
+ return (
151
+ <li
152
+ class="sui-search-select-option"
153
+ role="option"
154
+ value={x.value}
155
+ class:list={[
156
+ isSelected && `selected`,
157
+ x.disabled && "disabled"
158
+ ]}
159
+ id={isSelected ? `${name}-selected` : ""}
160
+ data-option-index={i}
161
+ data-value={x.value}
162
+ >
163
+ {x.label}
164
+ </li>
165
+ )
166
+ })
167
+ }
168
+ </ul>
169
+ </div>
170
+ <select class="sui-hidden-search-select" id={name} name={name} required={isRequired} multiple={multiple ? "" : undefined} hidden tabindex="-1">
171
+ <option value={""}> Select </option>
172
+ {
173
+ options.map((x) => {
174
+ const isSelected = Array.isArray(selected)
175
+ ? selected.map((y) => y && y.value).includes(x.value)
176
+ : selected?.value === x.value;
177
+ (
178
+ <option
179
+ value={x.value}
180
+ selected={isSelected}
181
+ disabled={x.disabled}
182
+ >
183
+ {x.label}
184
+ </option>
185
+ )
186
+ })
187
+ }
188
+ </select>
189
+ {multiple && max !== undefined && (
190
+ <span class="sui-search-select-max-span">
191
+ <span class="sui-search-select-select-count">0</span> / {max} selected
192
+ </span>
193
+ )}
194
+ {
195
+ multiple && Array.isArray(selected ?? []) && (
196
+ <div class="sui-search-select-badge-container">
197
+ {
198
+ ((selected ?? []) as Option[]).map((s) => s &&
199
+ <Badge class="sui-search-select-badge" data-value={s.value} size="sm" label={s.label} iconPosition="right" icon="x-mark" />
200
+ )
201
+ }
202
+ </div>
203
+ )
204
+ }
132
205
  </div>
133
206
  <script>
134
207
  import "studiocms:ui/scripts/searchselect"
@@ -21,6 +21,14 @@
21
21
  color: hsl(var(--danger-base));
22
22
  font-weight: 700;
23
23
  }
24
+ .sui-search-select-dropdown-container {
25
+ width: 100%;
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: 0.25rem;
29
+ position: relative;
30
+ height: fit-content;
31
+ }
24
32
  .sui-search-select-dropdown {
25
33
  position: absolute;
26
34
  width: 100%;
@@ -40,7 +48,7 @@
40
48
  }
41
49
  .sui-search-select-dropdown.above {
42
50
  top: auto;
43
- bottom: calc(100% - 18px + 0.25rem);
51
+ bottom: calc(100% - 1.5rem);
44
52
  }
45
53
  .sui-search-select-option,
46
54
  .empty-search-results {
@@ -49,6 +57,10 @@
49
57
  font-size: 0.975em;
50
58
  transition: all 0.15s ease;
51
59
  }
60
+ .empty-search-results:hover {
61
+ background-color: hsl(var(--background-step-2));
62
+ cursor: default;
63
+ }
52
64
  .sui-search-select-option.disabled {
53
65
  pointer-events: none;
54
66
  color: hsl(var(--text-muted));
@@ -57,11 +69,19 @@
57
69
  .sui-search-select-option.focused {
58
70
  background-color: hsl(var(--background-step-3));
59
71
  }
72
+ .sui-search-select-label[data-multiple=true] .sui-search-select-option.selected:hover,
73
+ .sui-search-select-label[data-multiple=true] .sui-search-select-option.selected:focus,
74
+ .sui-search-select-label[data-multiple=true] .sui-search-select-option.selected.focused {
75
+ background-color: hsl(var(--primary-hover));
76
+ }
60
77
  .sui-search-select-option.selected {
61
78
  background-color: hsl(var(--primary-base));
62
79
  color: hsl(var(--text-inverted));
63
80
  cursor: default;
64
81
  }
82
+ .sui-search-select-label[data-multiple=true] .sui-search-select-option.selected {
83
+ cursor: pointer;
84
+ }
65
85
  .sui-hidden-select {
66
86
  height: 0;
67
87
  width: 0;
@@ -85,6 +105,7 @@
85
105
  position: absolute;
86
106
  bottom: .675rem;
87
107
  right: .675rem;
108
+ pointer-events: none;
88
109
  }
89
110
  .sui-search-input-wrapper:has(input:focus) + .sui-search-select-dropdown {
90
111
  display: flex;
@@ -93,3 +114,45 @@
93
114
  .sui-search-select-dropdown:has(> li:active) {
94
115
  display: flex;
95
116
  }
117
+ .sui-search-select-badge-container {
118
+ width: 100%;
119
+ display: flex;
120
+ flex-wrap: wrap;
121
+ flex-direction: row;
122
+ gap: 0.5rem;
123
+ margin-top: 0.5rem;
124
+ }
125
+ .sui-search-select-label:has(.sui-search-select-max-span) .sui-search-select-badge-container {
126
+ margin-top: 0rem;
127
+ }
128
+ .sui-search-select-badge-container .sui-search-select-badge.sui-badge {
129
+ white-space: nowrap;
130
+ cursor: default;
131
+ position: relative;
132
+ padding-right: 2rem;
133
+ border-block: 1.5px solid rgba(0, 0, 0, 0);
134
+ }
135
+ .sui-search-select-badge-container .sui-search-select-badge.sui-badge svg {
136
+ position: absolute;
137
+ right: 0.25rem;
138
+ padding: 4px;
139
+ height: 20px;
140
+ width: 20px;
141
+ aspect-ratio: 1 / 1;
142
+ border-radius: 999px;
143
+ cursor: pointer;
144
+ outline: 1px solid hsla(var(--border), 0);
145
+ transition: background-color 0.15s ease, outline 0.15s ease;
146
+ }
147
+ .sui-search-select-badge-container .sui-search-select-badge.sui-badge svg:hover {
148
+ background-color: hsla(100, 100%, 95%, 0.2);
149
+ }
150
+ .sui-search-select-badge-container .sui-search-select-badge.sui-badge svg:active,
151
+ .sui-search-select-badge-container .sui-search-select-badge.sui-badge svg:focus {
152
+ background-color: hsla(100, 100%, 95%, 0.2);
153
+ outline: 1px solid hsl(var(--border));
154
+ }
155
+ .sui-search-select-max-span {
156
+ font-size: 0.875em;
157
+ color: hsl(var(--text-muted));
158
+ }
@@ -1,6 +1,19 @@
1
- interface Option {
1
+ type SearchSelectOption = {
2
2
  label: string;
3
3
  value: string;
4
4
  disabled?: boolean;
5
- }
5
+ };
6
+ type SearchSelectContainer = HTMLDivElement & {
7
+ input: HTMLInputElement | null;
8
+ dropdown: Element | null;
9
+ select: HTMLSelectElement | null;
10
+ };
11
+ type SearchSelectState = {
12
+ optionsMap: Record<string, SelectOption[]>;
13
+ isMultipleMap: Record<string, boolean>;
14
+ selectedOptionsMap: Record<string, string[]>;
15
+ placeholderMap: Record<string, string>;
16
+ focusIndex: number;
17
+ isSelectingOption: boolean;
18
+ };
6
19
  declare function loadSearchSelects(): void;