paris 0.10.1 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # paris
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e075cc: Combobox: Improved custom option props, allowing comboboxes to act more like auto-complete inputs
8
+
9
+ ### Patch Changes
10
+
11
+ - 2e075cc: Combobox: add Field overrides
12
+
13
+ ## 0.10.2
14
+
15
+ ### Patch Changes
16
+
17
+ - 014a642: Button: hide enhancers in loading state
18
+
3
19
  ## 0.10.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "paris",
3
3
  "author": "Sanil Chawla <sanil@slingshot.fm> (https://sanil.co)",
4
4
  "description": "Paris is Slingshot's React design system. It's a collection of reusable components, design tokens, and guidelines that help us build consistent, accessible, and performant user interfaces.",
5
- "version": "0.10.1",
5
+ "version": "0.11.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -14,8 +14,7 @@ import { Text } from '../text';
14
14
  import type { Enhancer } from '../../types/Enhancer';
15
15
  import { MemoizedEnhancer } from '../../helpers/renderEnhancer';
16
16
  import { pvar } from '../theme';
17
- import { Spinner } from '../icon';
18
- import { NotificationDot } from '../icon/NotificationDot';
17
+ import { Spinner, NotificationDot } from '../icon';
19
18
 
20
19
  const EnhancerSizes = {
21
20
  large: 13,
@@ -200,7 +199,7 @@ export const Button: FC<ButtonProps> = ({
200
199
  ),
201
200
  } : {}}
202
201
  >
203
- {!!startEnhancer && (
202
+ {!!(startEnhancer && !loading) && (
204
203
  <MemoizedEnhancer
205
204
  enhancer={startEnhancer}
206
205
  size={EnhancerSizes[size]}
@@ -215,7 +214,7 @@ export const Button: FC<ButtonProps> = ({
215
214
  )}
216
215
  </Text>
217
216
  )}
218
- {!!endEnhancer && (
217
+ {!!(endEnhancer && !loading) && (
219
218
  <MemoizedEnhancer
220
219
  enhancer={endEnhancer}
221
220
  size={EnhancerSizes[size]}
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable react-hooks/rules-of-hooks,react/no-children-prop */
2
2
  import type { Meta, StoryObj } from '@storybook/react';
3
3
  import { createElement, useState } from 'react';
4
- import type { Option } from './Combobox';
4
+ import type { ComboboxProps, Option } from './Combobox';
5
5
  import { Combobox } from './Combobox';
6
6
  import { Text } from '../text';
7
7
 
@@ -12,56 +12,78 @@ const meta: Meta<typeof Combobox> = {
12
12
  };
13
13
 
14
14
  export default meta;
15
- type Story = StoryObj<typeof Combobox>;
15
+ type Story = StoryObj<typeof Combobox<{ name: string }>>;
16
16
 
17
- export const Default: Story = {
18
- args: {
19
- label: 'Share',
20
- description: 'Search for a friend to share this document with.',
21
- placeholder: 'Search...',
22
- options: [
23
- {
24
- id: '1',
25
- node: createElement(Text, {
26
- kind: 'paragraphSmall',
27
- children: 'Mia Dolan',
28
- }),
29
- metadata: {
30
- name: 'Mia Dolan',
31
- },
17
+ const ComboboxArgs: ComboboxProps<{ name: string }> = {
18
+ label: 'Share',
19
+ description: 'Search for a friend to share this document with.',
20
+ placeholder: 'Search...',
21
+ options: [
22
+ {
23
+ id: '1',
24
+ node: createElement(Text, {
25
+ kind: 'paragraphSmall',
26
+ children: 'Mia Dolan',
27
+ }),
28
+ metadata: {
29
+ name: 'Mia Dolan',
32
30
  },
33
- {
34
- id: '2',
35
- node: createElement(Text, {
36
- kind: 'paragraphSmall',
37
- children: 'Sebastian Wilder',
38
- }),
39
- metadata: {
40
- name: 'Sebastian Wilder',
41
- },
31
+ },
32
+ {
33
+ id: '2',
34
+ node: 'SEB',
35
+ metadata: {
36
+ name: 'Sebastian Wilder',
42
37
  },
43
- {
44
- id: '3',
45
- node: createElement(Text, {
46
- kind: 'paragraphSmall',
47
- children: 'Amy Brandt',
48
- }),
49
- metadata: {
50
- name: 'Amy Brandt',
51
- },
38
+ },
39
+ {
40
+ id: '3',
41
+ node: createElement(Text, {
42
+ kind: 'paragraphSmall',
43
+ children: 'Amy Brandt',
44
+ }),
45
+ metadata: {
46
+ name: 'Amy Brandt',
47
+ },
48
+ },
49
+ {
50
+ id: '4',
51
+ node: createElement(Text, {
52
+ kind: 'paragraphSmall',
53
+ children: 'Laura Wilder',
54
+ }),
55
+ metadata: {
56
+ name: 'Laura Wilder',
52
57
  },
53
- {
54
- id: '4',
55
- node: createElement(Text, {
56
- kind: 'paragraphSmall',
57
- children: 'Laura Wilder',
58
- }),
58
+ },
59
+ ],
60
+ };
61
+
62
+ export const Default: Story = {
63
+ args: ComboboxArgs,
64
+ render: (args) => {
65
+ const [selected, setSelected] = useState<Option<{ name: string }> | null>(null);
66
+ const [inputValue, setInputValue] = useState<string>('');
67
+ return createElement('div', {
68
+ style: { minHeight: '200px' },
69
+ }, createElement(Combobox<{ name: string }>, {
70
+ ...args,
71
+ value: (selected?.id === null) ? {
72
+ id: null,
73
+ node: inputValue,
59
74
  metadata: {
60
- name: 'Laura Wilder',
75
+ name: inputValue,
61
76
  },
62
- },
63
- ],
77
+ } : selected as Option<{ name: string }> | null,
78
+ options: (args.options as Option<{ name: string }>[]).filter((o) => (o.metadata?.name as string || '').toLowerCase().includes(inputValue.toLowerCase())),
79
+ onChange: (e) => setSelected(e),
80
+ onInputChange: (e) => setInputValue(e),
81
+ }));
64
82
  },
83
+ };
84
+
85
+ export const AllowCustomValue: Story = {
86
+ args: { ...ComboboxArgs, allowCustomValue: true, customValueString: 'Add "%v"' },
65
87
  render: (args) => {
66
88
  const [selected, setSelected] = useState<Option | null>(null);
67
89
  const [inputValue, setInputValue] = useState<string>('');
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
4
- import { useId, useState } from 'react';
4
+ import {
5
+ useMemo, useId, useState,
6
+ } from 'react';
5
7
  import { Combobox as HCombobox, Transition } from '@headlessui/react';
6
8
  import clsx from 'clsx';
7
9
  import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -14,6 +16,7 @@ import { Text } from '../text';
14
16
  import type { InputProps } from '../input';
15
17
  import { MemoizedEnhancer } from '../../helpers/renderEnhancer';
16
18
  import { pget, theme } from '../theme';
19
+ import type { FieldProps } from '../field';
17
20
  import { Field } from '../field';
18
21
  import { Button } from '../button';
19
22
 
@@ -60,6 +63,11 @@ export type ComboboxProps<T extends Record<string, any>> = {
60
63
  * @default false
61
64
  */
62
65
  allowCustomValue?: boolean;
66
+ /**
67
+ * Whether to show the custom value option in the dropdown. This is irrelevant if `allowCustomValue` is `false`.
68
+ * @default true
69
+ */
70
+ showCustomValueOption?: boolean;
63
71
  /**
64
72
  * The text to use for the custom creation option. This should include a `%v` placeholder, which will be replaced with the user's input.
65
73
  *
@@ -67,6 +75,15 @@ export type ComboboxProps<T extends Record<string, any>> = {
67
75
  * @default Create "%v"...
68
76
  */
69
77
  customValueString?: string;
78
+ /**
79
+ * A function that will be called to create an {@link Option} based on the user's custom typed query value. This is useful for adding custom styling by allowing you to pass a custom `Option.node` based on the value. This overrides the `customValueString` prop.
80
+ * @param value
81
+ */
82
+ customValueToOption?: (value: string) => Option<T>;
83
+ /**
84
+ * Whether to hide the clear button when a value is selected. This will never be hidden if the selected option's node is not a strong, because there is no other way to clear the value as of now.
85
+ */
86
+ hideClearButton?: boolean;
70
87
  /**
71
88
  * The size of the options dropdown, in pixels.
72
89
  */
@@ -80,6 +97,7 @@ export type ComboboxProps<T extends Record<string, any>> = {
80
97
  * Prop overrides for other rendered elements. Overrides for the input itself should be passed directly to the component.
81
98
  */
82
99
  overrides?: {
100
+ field?: FieldProps;
83
101
  container?: ComponentPropsWithoutRef<'div'>;
84
102
  input?: ComponentPropsWithoutRef<'input'>;
85
103
  optionsContainer?: ComponentPropsWithoutRef<'div'>;
@@ -94,6 +112,10 @@ export type ComboboxProps<T extends Record<string, any>> = {
94
112
  /**
95
113
  * A Combobox component is used to render a searchable select.
96
114
  *
115
+ * When the selected option node is a strings, the combobox will act like an input even when an option is selected, allowing users to edit the selected option directly in order to pick a new one. To circumvent this and make selected options non-editable, pass nodes that are `Text` components instead.
116
+ *
117
+ * When `allowCustomValue` is `true`, a custom value option will be added to the dropdown. This option's text can be customized by passing a value for `customValueString`, where `%v` within the string is the user's input. You can provide an entirely custom node through `renderCustomValueOption`. By default, `onChange` will be called for every input change when custom values are allowed.
118
+ *
97
119
  * <hr />
98
120
  *
99
121
  * To use this component, import it as follows:
@@ -118,7 +140,10 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
118
140
  disabled,
119
141
  onInputChange,
120
142
  allowCustomValue,
143
+ showCustomValueOption = true,
121
144
  customValueString = 'Create "%v"',
145
+ customValueToOption,
146
+ hideClearButton = false,
122
147
  maxHeight = 320,
123
148
  hasOptionBorder = false,
124
149
  overrides,
@@ -127,6 +152,13 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
127
152
  const [selectedID, setSelectedID] = useState<string | null>(value?.id || null);
128
153
  const [query, setQuery] = useState('');
129
154
 
155
+ const optionsWithCustomValue = useMemo(() => ([
156
+ ...((allowCustomValue && customValueToOption) ? [
157
+ customValueToOption(query),
158
+ ] : []),
159
+ ...options,
160
+ ]), [allowCustomValue, customValueToOption, options, query]);
161
+
130
162
  return (
131
163
  <Field
132
164
  htmlFor={inputID}
@@ -140,13 +172,14 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
140
172
  label: overrides?.label,
141
173
  description: overrides?.description,
142
174
  }}
175
+ {...(overrides?.field ?? {})}
143
176
  >
144
177
  <HCombobox
145
178
  as="div"
146
179
  value={selectedID}
147
180
  onChange={(id) => {
148
181
  if (onChange) {
149
- const sel = options.find((o) => o.id === id);
182
+ const sel = optionsWithCustomValue.find((o) => o.id === id);
150
183
  if (sel) {
151
184
  onChange(sel);
152
185
  setSelectedID(sel.id);
@@ -178,16 +211,23 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
178
211
  </div>
179
212
  )}
180
213
  <div className={styles.content}>
181
- {value ? value.node : (
214
+ {(value?.node && typeof value.node !== 'string') ? value.node : (
182
215
  <HCombobox.Input
183
216
  id={inputID}
184
217
  {...overrides?.input}
185
218
  placeholder={placeholder}
186
219
  // value={query}
220
+ displayValue={(allowCustomValue && typeof value?.node === 'string') ? () => value.node as string : undefined}
187
221
  onChange={(e) => {
188
222
  setQuery(e.target.value);
189
223
  if (onInputChange) onInputChange(e.target.value);
190
224
  if (overrides?.input?.onChange) overrides.input.onChange(e);
225
+ if (allowCustomValue && e.target.value) {
226
+ onChange?.(customValueToOption?.(e.target.value) || {
227
+ id: null,
228
+ node: e.target.value,
229
+ });
230
+ }
191
231
  }}
192
232
  aria-disabled={disabled}
193
233
  data-status={disabled ? 'disabled' : (status || 'default')}
@@ -199,7 +239,8 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
199
239
  />
200
240
  )}
201
241
  </div>
202
- {!!value && (
242
+
243
+ {(!!value && (!hideClearButton || typeof value.node !== 'string')) && (
203
244
  <Button
204
245
  size="xs"
205
246
  shape="circle"
@@ -246,7 +287,7 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
246
287
  '--options-maxHeight': `${maxHeight}px`,
247
288
  } as CSSProperties}
248
289
  >
249
- {(allowCustomValue && query.length > 0) && (
290
+ {(allowCustomValue && showCustomValueOption && !customValueToOption && query.length > 0) && (
250
291
  <HCombobox.Option
251
292
  value={query}
252
293
  data-selected={false}
@@ -260,24 +301,29 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
260
301
  </Text>
261
302
  </HCombobox.Option>
262
303
  )}
263
- {(options || []).map((option) => (
264
- <HCombobox.Option
265
- key={option.id}
266
- value={option.id}
267
- data-selected={option.id === value}
268
- className={clsx(
269
- overrides?.option,
270
- styles.option,
271
- hasOptionBorder && styles.optionBorder,
272
- )}
273
- >
274
- {typeof option.node === 'string' ? (
275
- <Text as="span" kind="paragraphSmall">
276
- {option.node}
277
- </Text>
278
- ) : option.node}
279
- </HCombobox.Option>
280
- ))}
304
+ {
305
+ (
306
+ optionsWithCustomValue || []
307
+ )
308
+ .map((option) => (
309
+ <HCombobox.Option
310
+ key={option.id}
311
+ value={option.id}
312
+ data-selected={option.id === value}
313
+ className={clsx(
314
+ overrides?.option,
315
+ styles.option,
316
+ hasOptionBorder && styles.optionBorder,
317
+ )}
318
+ >
319
+ {typeof option.node === 'string' ? (
320
+ <Text as="span" kind="paragraphSmall">
321
+ {option.node}
322
+ </Text>
323
+ ) : option.node}
324
+ </HCombobox.Option>
325
+ ))
326
+ }
281
327
  </HCombobox.Options>
282
328
  </Transition>
283
329
  </HCombobox>