@bspk/ui 1.1.18 → 1.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bspk/ui",
3
- "version": "1.1.18",
3
+ "version": "1.1.20",
4
4
  "license": "CC-BY-4.0",
5
5
  "type": "module",
6
6
  "files": [
package/src/Dropdown.tsx CHANGED
@@ -21,7 +21,7 @@ export type DropdownProps<T extends DropdownOption = DropdownOption> = CommonPro
21
21
  'aria-label' | 'disabled' | 'id' | 'name' | 'readOnly' | 'size'
22
22
  > &
23
23
  InvalidPropsLibrary &
24
- Pick<MenuProps<T>, 'isMulti' | 'itemCount' | 'renderListItem'> & {
24
+ Pick<MenuProps<T>, 'isMulti' | 'itemCount' | 'renderListItem' | 'selectAll'> & {
25
25
  /**
26
26
  * Array of options to display in the dropdown
27
27
  *
@@ -52,7 +52,7 @@ export type DropdownProps<T extends DropdownOption = DropdownOption> = CommonPro
52
52
  /**
53
53
  * Placeholder for the dropdown
54
54
  *
55
- * @default Select...
55
+ * @default Select one...
56
56
  */
57
57
  placeholder?: string;
58
58
  /**
@@ -94,6 +94,7 @@ function Dropdown({
94
94
  isMulti,
95
95
  renderListItem,
96
96
  style: styleProp,
97
+ selectAll,
97
98
  }: DropdownProps) {
98
99
  const id = useId(propId);
99
100
 
@@ -142,6 +143,7 @@ function Dropdown({
142
143
  onChange?.(next);
143
144
  }}
144
145
  renderListItem={renderListItem}
146
+ selectAll={selectAll}
145
147
  selectedValues={selected}
146
148
  {...menuProps}
147
149
  />
package/src/Menu.tsx CHANGED
@@ -1,13 +1,16 @@
1
1
  import './menu.scss';
2
- import { ComponentProps, CSSProperties, useMemo } from 'react';
2
+ import { ComponentProps, CSSProperties, ReactNode, useMemo } from 'react';
3
3
 
4
4
  import { Checkbox } from './Checkbox';
5
5
  import { ListItem } from './ListItem';
6
- import { Txt } from './Txt';
7
6
  import { useId } from './hooks/useId';
8
7
 
9
8
  import { CommonProps, ElementProps, SetRef } from './';
10
9
 
10
+ const DEFAULT = {
11
+ selectAll: 'Select All',
12
+ };
13
+
11
14
  export const MIN_ITEM_COUNT = 3;
12
15
  export const MAX_ITEM_COUNT = 10;
13
16
 
@@ -68,12 +71,8 @@ export type MenuProps<T extends MenuItem = MenuItem> = CommonProps<'disabled' |
68
71
  items?: T[];
69
72
  /** A ref to the inner div element. */
70
73
  innerRef?: SetRef<HTMLDivElement>;
71
- /**
72
- * Message to display when no results are found
73
- *
74
- * @type multiline
75
- */
76
- noResultsMessage?: string;
74
+ /** Message to display when no results are found */
75
+ noResultsMessage?: ReactNode;
77
76
  /** The index of the currently highlighted item. */
78
77
  activeIndex?: number;
79
78
  /** The values of the selected items */
@@ -92,6 +91,17 @@ export type MenuProps<T extends MenuItem = MenuItem> = CommonProps<'disabled' |
92
91
  * @default false
93
92
  */
94
93
  isMulti?: boolean;
94
+ /**
95
+ * The label for the "Select All" option.
96
+ *
97
+ * Ignored if `isMulti` is false.
98
+ *
99
+ * If `isMulti` is `true`, defaults to "Select All". If a string, it will be used as the label. If false the select
100
+ * all option will not be rendered.
101
+ *
102
+ * @default false
103
+ */
104
+ selectAll?: boolean | string;
95
105
  /**
96
106
  * The function to call when the selected values change.
97
107
  *
@@ -118,24 +128,39 @@ function Menu({
118
128
  id: idProp,
119
129
  renderListItem,
120
130
  isMulti,
131
+ selectAll: selectAllProp,
121
132
  ...props
122
133
  }: ElementProps<MenuProps, 'div'>) {
123
134
  const menuId = useId(idProp);
124
- const items = Array.isArray(itemsProp) ? itemsProp : [];
125
- const itemCount = useMemo(
126
- () =>
135
+
136
+ const selectAll = useMemo(() => {
137
+ if (!isMulti) return false;
138
+ if (selectAllProp && typeof selectAllProp === 'string') return selectAllProp;
139
+ return selectAllProp === true ? DEFAULT.selectAll : false;
140
+ }, [isMulti, selectAllProp]);
141
+
142
+ const { items, itemCount } = useMemo(() => {
143
+ const itemsNext = Array.isArray(itemsProp) ? itemsProp : [];
144
+ return {
145
+ items: itemsNext,
127
146
  // Ensure itemCount is within the range of items.length
128
- Math.min(
129
- items.length,
147
+ itemCount: Math.min(
148
+ itemsNext.length,
130
149
  // pin itemCountProp to a range of 3 to 10
131
150
  Math.max(MIN_ITEM_COUNT, Math.min(itemCountProp, MAX_ITEM_COUNT)),
132
151
  ),
133
- [itemCountProp, items.length],
152
+ };
153
+ }, [itemCountProp, itemsProp]);
154
+
155
+ const allSelected = useMemo(
156
+ () => !!(items.length && items.every((item) => selectedValues.includes(item.value))),
157
+ [items, selectedValues],
134
158
  );
135
159
 
136
160
  return (
137
161
  <div
138
162
  {...props}
163
+ aria-multiselectable={isMulti || undefined}
139
164
  data-bspk="menu"
140
165
  data-disabled={disabled || undefined}
141
166
  data-item-count={itemCount || undefined}
@@ -145,87 +170,101 @@ function Menu({
145
170
  role="listbox"
146
171
  style={{ ...props.style, '--item-count': itemCount } as CSSProperties}
147
172
  >
148
- {items.length ? (
149
- items.map((item, index) => {
150
- const itemId = item.id || menuItemId(menuId, index);
151
-
152
- const selected = Array.isArray(selectedValues) && selectedValues.includes(item.value);
153
-
154
- const renderProps = renderListItem?.({
155
- activeIndex,
156
- index,
157
- item,
158
- selectedValues,
159
- isMulti,
160
- menuId: menuId || '',
161
- selected,
162
- itemId,
163
- });
164
-
165
- return (
166
- <ListItem
167
- {...renderProps}
168
- active={activeIndex === index || undefined}
169
- aria-disabled={item.disabled || undefined}
170
- aria-posinset={index + 1}
171
- aria-selected={selected || undefined}
172
- as="button"
173
- data-menu-item
174
- data-selected={selected || undefined}
175
- disabled={item.disabled || undefined}
176
- id={itemId}
177
- key={itemId}
178
- label={renderProps?.label?.toString() || item.label?.toString()}
179
- onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
180
- if (renderProps) renderProps?.onClick?.(event);
181
-
182
- if (onChange) {
183
- if (!isMulti) {
184
- onChange?.([item.value], event);
185
- return;
186
- }
187
- onChange(
188
- selected
189
- ? selectedValues.filter((value) => value !== item.value)
190
- : [...selectedValues, item.value],
191
- event,
192
- );
193
- }
173
+ {isMulti && selectAll && (
174
+ <ListItem
175
+ as="button"
176
+ data-selected={allSelected || undefined}
177
+ key="select-all"
178
+ label={selectAll}
179
+ onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
180
+ onChange?.(allSelected ? [] : items.map((item) => item.value), event);
181
+ }}
182
+ role="option"
183
+ tabIndex={-1}
184
+ trailing={
185
+ <Checkbox
186
+ aria-label={selectAll}
187
+ checked={!!allSelected}
188
+ name=""
189
+ onChange={(checked) => {
190
+ onChange?.(checked ? items.map((item) => item.value) : []);
194
191
  }}
195
- role="option"
196
- tabIndex={-1}
197
- trailing={
198
- isMulti ? (
199
- <Checkbox
200
- aria-label={item.label}
201
- checked={selected}
202
- name={item.value}
203
- onChange={(checked) => {
204
- onChange?.(
205
- checked
206
- ? selectedValues.filter((value) => value !== item.value)
207
- : [...selectedValues, item.value],
208
- );
209
- }}
210
- value={item.value}
211
- />
212
- ) : (
213
- renderProps?.trailing
214
- )
215
- }
192
+ value=""
216
193
  />
217
- );
218
- })
219
- ) : (
220
- <>
221
- <Txt as="div" variant="heading-h5">
222
- No results found
223
- </Txt>
224
- <Txt as="div" variant="body-base">
225
- {noResultsMessage}
226
- </Txt>
227
- </>
194
+ }
195
+ />
228
196
  )}
197
+ {items.length
198
+ ? items.map((item, index) => {
199
+ const itemId = item.id || menuItemId(menuId, index);
200
+
201
+ const selected = Boolean(Array.isArray(selectedValues) && selectedValues.includes(item.value));
202
+
203
+ const renderProps = renderListItem?.({
204
+ activeIndex,
205
+ index,
206
+ item,
207
+ selectedValues,
208
+ isMulti,
209
+ menuId: menuId || '',
210
+ selected,
211
+ itemId,
212
+ });
213
+
214
+ return (
215
+ <ListItem
216
+ {...renderProps}
217
+ active={activeIndex === index || undefined}
218
+ aria-disabled={item.disabled || undefined}
219
+ aria-posinset={index + 1}
220
+ aria-selected={selected || undefined}
221
+ as="button"
222
+ //data-selected={selected || undefined}
223
+ disabled={item.disabled || undefined}
224
+ id={itemId}
225
+ key={itemId}
226
+ label={renderProps?.label?.toString() || item.label?.toString()}
227
+ onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
228
+ if (renderProps) renderProps?.onClick?.(event);
229
+
230
+ if (onChange) {
231
+ if (!isMulti) {
232
+ onChange?.([item.value], event);
233
+ return;
234
+ }
235
+ onChange(
236
+ selected
237
+ ? selectedValues.filter((value) => value !== item.value)
238
+ : [...selectedValues, item.value],
239
+ event,
240
+ );
241
+ }
242
+ }}
243
+ role="option"
244
+ tabIndex={-1}
245
+ trailing={
246
+ isMulti ? (
247
+ <Checkbox
248
+ aria-label={item.label}
249
+ checked={selected}
250
+ name={item.value}
251
+ onChange={(checked) => {
252
+ onChange?.(
253
+ checked
254
+ ? selectedValues.filter((value) => value !== item.value)
255
+ : [...selectedValues, item.value],
256
+ );
257
+ }}
258
+ value={item.value}
259
+ />
260
+ ) : (
261
+ renderProps?.trailing
262
+ )
263
+ }
264
+ />
265
+ );
266
+ })
267
+ : noResultsMessage}
229
268
  </div>
230
269
  );
231
270
  }
package/src/SearchBar.tsx CHANGED
@@ -5,21 +5,26 @@ import { useRef } from 'react';
5
5
  import { MenuItem, MenuProps, Menu } from './Menu';
6
6
  import { Portal } from './Portal';
7
7
  import { TextInputProps, TextInput } from './TextInput';
8
+ import { Txt } from './Txt';
8
9
  import { useFloatingMenu } from './hooks/useFloatingMenu';
9
10
  import { useId } from './hooks/useId';
10
11
  //import { useFloatingMenu } from './hooks/useFloatingMenu';
11
12
 
12
- export type SearchBarProps = Pick<MenuProps, 'itemCount' | 'items' | 'noResultsMessage'> &
13
+ export type SearchBarProps<T extends MenuItem = MenuItem> = Pick<MenuProps<T>, 'itemCount' | 'noResultsMessage'> &
13
14
  Pick<TextInputProps, 'aria-label' | 'id' | 'inputRef' | 'name' | 'placeholder' | 'size'> & {
14
- /** The current value of the search bar. */
15
- searchValue?: string;
15
+ /**
16
+ * The current value of the search bar.
17
+ *
18
+ * @default Search
19
+ */
20
+ value?: string;
16
21
  /**
17
22
  * Handler for state updates.
18
23
  *
19
24
  * @type (value: String) => void
20
25
  * @required
21
26
  */
22
- setSearchValue: (value: string) => void;
27
+ onChange: (value: string) => void;
23
28
  /*
24
29
  * Handler for item selection.
25
30
  *
@@ -27,6 +32,38 @@ export type SearchBarProps = Pick<MenuProps, 'itemCount' | 'items' | 'noResultsM
27
32
  * @required
28
33
  */
29
34
  onSelect: (item?: MenuItem) => void;
35
+ /**
36
+ * Content to display in the menu.
37
+ *
38
+ * @example
39
+ * [
40
+ * { value: '1', label: 'Apple Pie' },
41
+ * { value: '2', label: 'Banana Split' },
42
+ * { value: '3', label: 'Cherry Tart' },
43
+ * { value: '4', label: 'Dragonfruit Sorbet' },
44
+ * { value: '5', label: 'Elderberry Jam' },
45
+ * { value: '6', label: 'Fig Newton' },
46
+ * { value: '7', label: 'Grape Soda' },
47
+ * { value: '8', label: 'Honeydew Smoothie' },
48
+ * { value: '9', label: 'Ice Cream Sandwich' },
49
+ * { value: '10', label: 'Jackfruit Pudding' },
50
+ * ];
51
+ *
52
+ * @type Array<MenuItem>
53
+ */
54
+ items?: T[];
55
+ /**
56
+ * Message to display when no results are found
57
+ *
58
+ * @type multiline
59
+ */
60
+ noResultsMessage?: string;
61
+ /**
62
+ * Whether to show or hide menu.
63
+ *
64
+ * @default true
65
+ */
66
+ showMenu?: boolean;
30
67
  };
31
68
 
32
69
  /**
@@ -45,8 +82,9 @@ function SearchBar({
45
82
  name,
46
83
  size = 'medium',
47
84
  onSelect,
48
- searchValue,
49
- setSearchValue,
85
+ value,
86
+ onChange,
87
+ showMenu = true,
50
88
  }: SearchBarProps) {
51
89
  const id = useId(idProp);
52
90
  const {
@@ -61,49 +99,66 @@ function SearchBar({
61
99
 
62
100
  return (
63
101
  <>
64
- <TextInput
65
- aria-label={ariaLabel}
66
- autoComplete="off"
67
- containerRef={triggerRef}
68
- data-bspk="search-bar"
69
- id={id}
70
- inputRef={(node) => {
71
- inputRef?.(node || null);
72
- inputRefLocal.current = node;
73
- }}
74
- leading={<SvgSearch />}
75
- name={name}
76
- onChange={(str) => setSearchValue(str)}
77
- placeholder={placeholder}
78
- size={size}
79
- value={searchValue}
80
- {...triggerProps}
81
- onClick={(event) => {
82
- if (items?.length) onClick(event);
83
- }}
84
- onKeyDownCapture={(event) => {
85
- const handled = onKeyDownCapture(event);
102
+ <div data-bspk="search-bar">
103
+ <TextInput
104
+ aria-label={ariaLabel}
105
+ autoComplete="off"
106
+ containerRef={triggerRef}
107
+ id={id}
108
+ inputRef={(node) => {
109
+ inputRef?.(node || null);
110
+ inputRefLocal.current = node;
111
+ }}
112
+ leading={<SvgSearch />}
113
+ name={name}
114
+ onChange={(str) => onChange(str)}
115
+ placeholder={placeholder}
116
+ size={size}
117
+ value={value}
118
+ {...triggerProps}
119
+ onClick={(event) => {
120
+ if (items?.length) onClick(event);
121
+ }}
122
+ onKeyDownCapture={(event) => {
123
+ const handled = onKeyDownCapture(event);
86
124
 
87
- if (handled) return;
125
+ if (handled) return;
88
126
 
89
- inputRefLocal.current?.focus();
90
- }}
91
- />
92
- <Portal>
93
- <Menu
94
- itemCount={itemCount}
95
- items={items}
96
- noResultsMessage={noResultsMessage}
97
- onChange={(selectedValues, event) => {
98
- event?.preventDefault();
99
- const item = items?.find((i) => i.value === selectedValues[0]);
100
- onSelect?.(item);
101
- setSearchValue(item?.label || '');
102
- closeMenu();
127
+ inputRefLocal.current?.focus();
103
128
  }}
104
- {...menuProps}
105
129
  />
106
- </Portal>
130
+ </div>
131
+ {showMenu && (
132
+ <Portal>
133
+ <Menu
134
+ itemCount={itemCount}
135
+ items={items}
136
+ noResultsMessage={
137
+ !!value?.length &&
138
+ !items?.length && (
139
+ <>
140
+ <Txt as="div" variant="heading-h5">
141
+ No results found
142
+ </Txt>
143
+ {noResultsMessage && (
144
+ <Txt as="div" variant="body-base">
145
+ {noResultsMessage}
146
+ </Txt>
147
+ )}
148
+ </>
149
+ )
150
+ }
151
+ onChange={(selectedValues, event) => {
152
+ event?.preventDefault();
153
+ const item = items?.find((i) => i.value === selectedValues[0]);
154
+ onSelect?.(item);
155
+ onChange(item?.label || '');
156
+ closeMenu();
157
+ }}
158
+ {...menuProps}
159
+ />
160
+ </Portal>
161
+ )}
107
162
  </>
108
163
  );
109
164
  }
package/src/avatar.scss CHANGED
@@ -1,6 +1,7 @@
1
1
  [data-bspk='avatar'] {
2
2
  --height: var(--spacing-sizing-10);
3
3
  --font: var(--labels-base);
4
+ --svg-size: var(--spacing-sizing-10);
4
5
 
5
6
  &:not([data-color]) {
6
7
  --foreground: var(--foreground-neutral-on-surface);
@@ -28,49 +29,69 @@
28
29
  max-width: 100%;
29
30
  }
30
31
 
32
+ svg {
33
+ width: var(--svg-size);
34
+ height: var(--svg-size);
35
+ }
36
+
37
+ [data-icon] {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ }
42
+
31
43
  &[data-size='x-small'] {
32
44
  --height: var(--spacing-sizing-06);
33
45
  --font: var(--labels-x-small);
46
+ --svg-size: var(--spacing-sizing-04);
34
47
  }
35
48
 
36
49
  &[data-size='small'] {
37
50
  --height: var(--spacing-sizing-08);
38
51
  --font: var(--labels-small);
52
+ --svg-size: var(--spacing-sizing-05);
39
53
  }
40
54
 
41
55
  &[data-size='medium'] {
42
56
  --height: var(--spacing-sizing-10);
43
57
  --font: var(--labels-base);
58
+ --svg-size: var(--spacing-sizing-05);
44
59
  }
45
60
 
46
61
  &[data-size='large'] {
47
62
  --height: var(--spacing-sizing-12);
48
63
  --font: var(--labels-large);
64
+ --svg-size: var(--spacing-sizing-06);
49
65
  }
50
66
 
51
67
  &[data-size='x-large'] {
52
68
  --height: var(--spacing-sizing-14);
53
69
  --font: var(--subheader-x-large);
70
+ --svg-size: var(--spacing-sizing-08);
54
71
  }
55
72
 
56
73
  &[data-size='xx-large'] {
57
74
  --height: var(--spacing-sizing-17);
58
75
  --font: var(--subheader-xx-large);
76
+ --svg-size: var(--spacing-sizing-09);
59
77
  }
60
78
 
61
79
  &[data-size='xxx-large'] {
62
80
  --height: var(--spacing-sizing-19);
63
81
  --font: var(--display-regular-small);
82
+ --svg-size: var(--spacing-sizing-12);
64
83
  }
65
84
 
66
85
  &[data-size='xxxx-large'] {
67
86
  --height: var(--spacing-sizing-21);
68
87
  --font: var(--display-regular-medium);
88
+ --svg-size: var(--spacing-sizing-15);
69
89
  }
70
90
 
71
91
  &[data-size='xxxxx-large'] {
72
92
  --height: var(--spacing-sizing-23);
73
93
  --font: var(--display-regular-large);
94
+ --svg-size: var(--spacing-sizing-17);
74
95
  }
75
96
  }
76
97
 
@@ -19,6 +19,7 @@ import { ModalProps } from '../Modal';
19
19
  import { Popover, PopoverProps } from '../Popover';
20
20
  import { ProgressionStepperProps } from '../ProgressionStepper';
21
21
  import { Radio } from '../Radio';
22
+ import { SearchBarProps } from '../SearchBar';
22
23
  import { SegmentedControlProps } from '../SegmentedControl';
23
24
  import { Switch } from '../Switch';
24
25
  import { TabGroupProps } from '../TabGroup';
@@ -550,6 +551,32 @@ export const examples: (setState: DemoSetState, action: DemoAction) => Record<st
550
551
  },
551
552
  ]),
552
553
  },
554
+ SearchBar: {
555
+ containerStyle: { width: '400px' },
556
+ render: ({ props: state, Component, preset }) => {
557
+ const props = { ...state } as SearchBarProps;
558
+
559
+ if (preset?.label === 'Show Filtered Items') {
560
+ const searchValue = (props.value as string | undefined)?.trim()?.toLowerCase() || '';
561
+ if (Array.isArray(props.items) && searchValue.length)
562
+ props.items = props.items?.filter((item: MenuItem) =>
563
+ item.label.toLowerCase().includes(searchValue),
564
+ );
565
+ props.showMenu = !!searchValue;
566
+ }
567
+
568
+ return <Component {...props} items={props.items || []} />;
569
+ },
570
+ presets: setPresets<SearchBarProps>([
571
+ {
572
+ // we change the items and showMenu based on the input value
573
+ label: 'Show Filtered Items',
574
+ state: {
575
+ showMenu: false,
576
+ },
577
+ },
578
+ ]),
579
+ },
553
580
  TextInput: {
554
581
  containerStyle: { width: '280px' },
555
582
  presets: setPresets<TextInputProps>([
package/src/dropdown.scss CHANGED
@@ -56,6 +56,10 @@
56
56
  }
57
57
  }
58
58
 
59
+ [data-bspk='list-item'][data-selected] {
60
+ background: var(--surface-brand-primary-highlight);
61
+ }
62
+
59
63
  &[data-size='small'] {
60
64
  --dropdown-height: var(--spacing-sizing-08);
61
65
  --dropdown-font: var(--body-small);
@@ -79,7 +79,8 @@ export function useKeyboardNavigation(
79
79
  if (next >= itemElements.length) next = 0;
80
80
 
81
81
  itemElements.forEach((el, index) => {
82
- el.dataset.selected = index === next ? 'true' : undefined;
82
+ if (index === next) el.setAttribute('data-selected', 'true');
83
+ else el.removeAttribute('data-selected');
83
84
  });
84
85
 
85
86
  scrollElementIntoView(itemElements[next], containerElement);
@@ -90,7 +91,7 @@ export function useKeyboardNavigation(
90
91
  return {
91
92
  handleKeyNavigation: handleArrowKeyNavigation,
92
93
  selectedIndex,
93
- selectedId: selectedIndex === -1 ? undefined : containerElement.children[selectedIndex].id,
94
+ selectedId: selectedIndex === -1 ? undefined : containerElement.children[selectedIndex]?.id,
94
95
  setSelectedIndex: setSelectedIndex,
95
96
  };
96
97
  }
package/src/menu.scss CHANGED
@@ -30,6 +30,10 @@
30
30
  [data-bspk='list-item'] {
31
31
  min-height: var(--item-size);
32
32
  height: var(--item-size);
33
+
34
+ &[data-selected] {
35
+ background-color: var(--surface-brand-primary-highlight);
36
+ }
33
37
  }
34
38
  }
35
39