paris 0.13.5 → 0.14.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,23 @@
1
1
  # paris
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a805866: Select: added `multiple` prop to convert listbox into a multiselect
8
+
9
+ ### Patch Changes
10
+
11
+ - fa49d95: Select: updated hover state for segemented control
12
+ - fa49d95: InformationalTooltip: added `defaultOpen` prop, replacing previous `open` prop
13
+ - fa49d95: InformationalTooltip: added exit animation, and animations use duration theme variables
14
+
15
+ ## 0.13.6
16
+
17
+ ### Patch Changes
18
+
19
+ - 1d092ca: Dropdowns: fix z-index positioning with transition containers
20
+
3
21
  ## 0.13.5
4
22
 
5
23
  ### 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.13.5",
5
+ "version": "0.14.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
4
4
  import {
5
+ Fragment,
6
+
5
7
  useMemo, useId, useState,
6
8
  } from 'react';
7
9
  import { Combobox as HCombobox, Transition } from '@headlessui/react';
@@ -282,6 +284,8 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
282
284
  )}
283
285
  </div>
284
286
  <Transition
287
+ as="div"
288
+ className={dropdownStyles.transitionContainer}
285
289
  enter={dropdownStyles.transition}
286
290
  enterFrom={dropdownStyles.enterFrom}
287
291
  enterTo={dropdownStyles.enterTo}
@@ -13,17 +13,15 @@ const meta: Meta<typeof InformationalTooltip> = {
13
13
  export default meta;
14
14
  type Story = StoryObj<typeof InformationalTooltip>;
15
15
 
16
- const render: Story['render'] = (args) =>
17
- // eslint-disable-next-line react-hooks/rules-of-hooks
18
- createElement('div', {
19
- style: { minHeight: '200px' },
20
- }, createElement(InformationalTooltip, {
21
- ...args,
22
- }));
16
+ const render: Story['render'] = (args) => createElement('div', {
17
+ style: { minHeight: '200px' },
18
+ }, createElement(InformationalTooltip, {
19
+ ...args,
20
+ }));
23
21
 
24
22
  export const Default: Story = {
25
23
  args: {
26
- children: 'If you are being payed on 1099s (through transfer outs) you need to pay taxes quarterly. The amount you pay each quarter is a portion of your estimated tax burden for the year, based on your anticipated income amount. If you over/underpay, you will be refunded/owe the difference at the end of the year. ',
24
+ children: 'If you are being paid on 1099s (through transfer outs) you need to pay taxes quarterly. The amount you pay each quarter is a portion of your estimated tax burden for the year, based on your anticipated income amount. If you over/underpay, you will be refunded/owe the difference at the end of the year. ',
27
25
  heading: 'Quarterly taxes',
28
26
  },
29
27
  render,
@@ -51,15 +49,6 @@ export const CustomTrigger: Story = {
51
49
  render,
52
50
  };
53
51
 
54
- export const Open: Story = {
55
- args: {
56
- children: 'Open boolean set to true',
57
- size: 'medium',
58
- open: true,
59
- },
60
- render,
61
- };
62
-
63
52
  export const CustomAlign: Story = {
64
53
  args: {
65
54
  children: 'With some text below',
@@ -1,11 +1,12 @@
1
1
  import type { FC, ReactNode } from 'react';
2
+ import { useState } from 'react';
2
3
  import clsx from 'clsx';
3
4
  import * as RadixTooltip from '@radix-ui/react-tooltip';
4
- import { motion } from 'framer-motion';
5
+ import { motion, AnimatePresence } from 'framer-motion';
5
6
  import styles from './InformationalTooltip.module.scss';
6
7
  import { TextWhenString } from '../utility';
7
8
  import { Icon, Info } from '../icon';
8
- import { pvar } from '../theme';
9
+ import { pvar, pget } from '../theme';
9
10
 
10
11
  export type InformationalTooltipProps = {
11
12
  /**
@@ -41,15 +42,10 @@ export type InformationalTooltipProps = {
41
42
  */
42
43
  align?: 'start' | 'center' | 'end';
43
44
  /**
44
- * The tooltip's open state.
45
+ * Whether the tooltip should be open by default.
46
+ * @default false
45
47
  */
46
- open?: boolean;
47
- /**
48
- * Event handler called when the open state of the tooltip changes.
49
- *
50
- * @param value {boolean} - The new open state of the tooltip.
51
- */
52
- onOpenChange?: (value: boolean) => void | Promise<void>;
48
+ defaultOpen?: boolean;
53
49
  };
54
50
 
55
51
  /**
@@ -73,53 +69,84 @@ export const InformationalTooltip: FC<InformationalTooltipProps> = ({
73
69
  side = 'bottom',
74
70
  sideOffset = 6,
75
71
  align = 'start',
76
- open,
77
- onOpenChange,
78
- }) => (
79
- <RadixTooltip.Provider
80
- delayDuration={150}
81
- >
82
- <RadixTooltip.Root
83
- open={open}
84
- onOpenChange={onOpenChange}
72
+ defaultOpen = false,
73
+ }) => {
74
+ const [isOpen, setOpen] = useState(defaultOpen);
75
+
76
+ /**
77
+ * Converts a CSS time value string to milliseconds
78
+ * @param timeValue - The CSS time value (e.g. '100ms', '0.5s')
79
+ * @returns The time value in milliseconds
80
+ * @throws Error if the time value format is invalid
81
+ */
82
+ const parseCSSTime = (timeValue: string): number => {
83
+ // Match the value and unit
84
+ const match = timeValue.match(/^([\d.]+)(ms|s)$/);
85
+
86
+ if (!match) {
87
+ console.warn('Invalid CSS time format. Expected formats: "100ms", "0.5s"');
88
+ return 0;
89
+ }
90
+
91
+ const [, value, unit] = match;
92
+ const numValue = parseFloat(value);
93
+
94
+ // Convert to milliseconds based on the unit
95
+ return unit === 's' ? numValue * 1000 : numValue;
96
+ };
97
+
98
+ return (
99
+ <RadixTooltip.Provider
100
+ delayDuration={parseCSSTime(pget('new.animations.duration.normal'))}
85
101
  >
86
- <RadixTooltip.Trigger>
87
- {!trigger ? (
88
- <Icon icon={Info} size={14} className={styles.icon} style={{ color: pvar('new.colors.contentSecondary') }} />
89
- ) : (
90
- <>
91
- {trigger}
92
- </>
93
- )}
94
- </RadixTooltip.Trigger>
95
- <RadixTooltip.Portal>
96
- <RadixTooltip.Content
97
- side={side}
98
- sideOffset={sideOffset}
99
- align={align}
100
- >
101
- <motion.div
102
- initial={{ opacity: 0, y: 3 }}
103
- animate={{ opacity: 1, y: 0 }}
104
- transition={{ duration: 0.2 }}
105
- className={clsx(styles.tooltip, styles[size])}
106
- >
107
- {heading && (
108
- <div className={styles.heading}>
109
- {headingIcon === null ? null : headingIcon || (
110
- <Icon icon={Info} size={14} className={styles.icon} />
111
- )}
112
- <TextWhenString as="p" kind="paragraphXSmall" weight="medium">
113
- {heading}
114
- </TextWhenString>
115
- </div>
116
- )}
117
- <TextWhenString as="p" kind="paragraphXSmall">
118
- {children}
119
- </TextWhenString>
120
- </motion.div>
121
- </RadixTooltip.Content>
122
- </RadixTooltip.Portal>
123
- </RadixTooltip.Root>
124
- </RadixTooltip.Provider>
125
- );
102
+ <RadixTooltip.Root
103
+ open={isOpen}
104
+ onOpenChange={setOpen}
105
+ >
106
+ <RadixTooltip.Trigger>
107
+ {!trigger ? (
108
+ <Icon icon={Info} size={14} className={styles.icon} style={{ color: pvar('new.colors.contentSecondary') }} />
109
+ ) : (
110
+ <>
111
+ {trigger}
112
+ </>
113
+ )}
114
+ </RadixTooltip.Trigger>
115
+ <AnimatePresence>
116
+ {isOpen && (
117
+ <RadixTooltip.Portal forceMount>
118
+ <RadixTooltip.Content
119
+ side={side}
120
+ sideOffset={sideOffset}
121
+ align={align}
122
+ asChild
123
+ >
124
+ <motion.div
125
+ initial={{ opacity: 0, y: 3 }}
126
+ animate={{ opacity: 1, y: 0 }}
127
+ exit={{ opacity: 0, y: 3 }}
128
+ transition={{ duration: parseCSSTime(pget('new.animations.duration.normal')) / 1000 }}
129
+ className={clsx(styles.tooltip, styles[size])}
130
+ >
131
+ {heading && (
132
+ <div className={styles.heading}>
133
+ {headingIcon === null ? null : headingIcon || (
134
+ <Icon icon={Info} size={14} className={styles.icon} />
135
+ )}
136
+ <TextWhenString as="p" kind="paragraphXSmall" weight="medium">
137
+ {heading}
138
+ </TextWhenString>
139
+ </div>
140
+ )}
141
+ <TextWhenString as="p" kind="paragraphXSmall">
142
+ {children}
143
+ </TextWhenString>
144
+ </motion.div>
145
+ </RadixTooltip.Content>
146
+ </RadixTooltip.Portal>
147
+ )}
148
+ </AnimatePresence>
149
+ </RadixTooltip.Root>
150
+ </RadixTooltip.Provider>
151
+ );
152
+ };
@@ -64,7 +64,8 @@ Menu.Items = ({
64
64
  className, children, position = 'left', ...props
65
65
  }) => (
66
66
  <Transition
67
- as={Fragment}
67
+ as="div"
68
+ className={dropdownStyles.transitionContainer}
68
69
  enter={dropdownStyles.transition}
69
70
  enterFrom={dropdownStyles.enterFrom}
70
71
  enterTo={dropdownStyles.enterTo}
@@ -24,11 +24,6 @@
24
24
  align-items: center;
25
25
  }
26
26
 
27
- .transitionContainer {
28
- position: relative;
29
- z-index: 10;
30
- }
31
-
32
27
  .options {
33
28
  max-height: var(--options-maxHeight, auto);
34
29
  overflow-y: auto;
@@ -286,8 +281,7 @@
286
281
  }
287
282
 
288
283
  &:hover {
289
- background-color: var(--pte-new-colors-surfaceSecondary);
290
- color: var(--pte-new-colors-contentSecondary);
284
+ color: var(--pte-new-colors-contentPrimary);
291
285
  }
292
286
 
293
287
  &[data-headlessui-state~="checked"] {
@@ -15,13 +15,20 @@ type Story = StoryObj<typeof Select>;
15
15
  const render: Story['render'] = (args) => {
16
16
  // eslint-disable-next-line react-hooks/rules-of-hooks
17
17
  const [selected, setSelected] = useState<string | null>(null);
18
+ // eslint-disable-next-line react-hooks/rules-of-hooks
19
+ const [selectedMultiple, setSelectedMultiple] = useState<string[] | null>([]);
18
20
  return createElement('div', {
19
21
  style: { minHeight: '400px' },
20
- }, createElement(Select, {
22
+ }, createElement(Select, args.multiple ? {
21
23
  ...args,
22
- value: selected,
23
- onChange: (e) => setSelected(e),
24
- }));
24
+ value: selectedMultiple,
25
+ onChange: (value: string[] | null) => setSelectedMultiple(value),
26
+ }
27
+ : {
28
+ ...args,
29
+ value: selected,
30
+ onChange: (value: string | null) => setSelected(value),
31
+ }));
25
32
  };
26
33
 
27
34
  export const Default: Story = {
@@ -112,6 +119,21 @@ export const Card: Story = {
112
119
  render,
113
120
  };
114
121
 
122
+ export const Multiple: Story = {
123
+ args: {
124
+ label: 'Release type',
125
+ description: 'Select the type of release you want to create.',
126
+ options: [
127
+ { id: '1', node: 'Single' },
128
+ { id: '2', node: 'EP' },
129
+ { id: '3', node: 'Album (LP)' },
130
+ ],
131
+ multiple: true,
132
+ multipleItemsName: 'releases',
133
+ },
134
+ render,
135
+ };
136
+
115
137
  export const Segmented: Story = {
116
138
  args: {
117
139
  label: 'Donation',
@@ -29,7 +29,7 @@ export type Option<T = Record<string, any>> = {
29
29
  disabled?: boolean,
30
30
  metadata?: T,
31
31
  };
32
- export type SelectProps<T = Record<string, any>> = {
32
+ export type CommonSelectProps<T = Record<string, any>> = {
33
33
  /**
34
34
  * The {@link Option}s to render in the select box.
35
35
  *
@@ -38,21 +38,6 @@ export type SelectProps<T = Record<string, any>> = {
38
38
  * For type safety, you can pass in a type parameter to `SelectProps` component. This will be used as the type for the `metadata` property of each option.
39
39
  */
40
40
  options: Option<T>[];
41
- /**
42
- * The option ID to render as selected in the select box.
43
- *
44
- * This should exactly match one of the option IDs passed in the `options` prop. If `null`, no option will be selected.
45
- */
46
- value?: Option<T>['id'] | null;
47
- /**
48
- * The interaction handler for the Select.
49
- */
50
- onChange?: (value: Option<T>['id'] | null) => void | Promise<void>;
51
- /**
52
- * The visual variant of the Select. `listbox` will render as a dropdown menu, `radio` will render as a radio group, `card` will render as selectable cards, and `segmented` will render as a segmented control.
53
- * @default listbox
54
- */
55
- kind?: 'listbox' | 'radio' | 'card' | 'segmented';
56
41
  /**
57
42
  * The size of the options dropdown, in pixels. Only applicable to kind="listbox".
58
43
  */
@@ -67,7 +52,6 @@ export type SelectProps<T = Record<string, any>> = {
67
52
  * @default compact
68
53
  */
69
54
  segmentedHeight?: 'compact' | 'tall';
70
-
71
55
  /**
72
56
  * Prop overrides for other rendered elements. Overrides for the input itself should be passed directly to the component.
73
57
  */
@@ -83,6 +67,55 @@ export type SelectProps<T = Record<string, any>> = {
83
67
  }
84
68
  } & Omit<InputProps, 'type' | 'overrides'>;
85
69
 
70
+ export type SingleSelectProps<T = Record<string, any>> = {
71
+ /**
72
+ * The option ID to render as selected in the select box.
73
+ *
74
+ * This should exactly match the option IDs passed in the `options` prop. If `null`, no option will be selected.
75
+ */
76
+ value?: Option<T>['id'] | null;
77
+ /**
78
+ * The interaction handler for the Select.
79
+ */
80
+ onChange?: (value: Option<T>['id'] | null) => void | Promise<void>;
81
+ /**
82
+ * The visual variant of the Select. `listbox` will render as a dropdown menu, `radio` will render as a radio group, `card` will render as selectable cards, and `segmented` will render as a segmented control.
83
+ * @default listbox
84
+ */
85
+ kind?: 'listbox' | 'radio' | 'card' | 'segmented';
86
+ multiple?: false;
87
+ multipleItemsName?: never;
88
+ } & CommonSelectProps;
89
+
90
+ export type MultiSelectProps<T = Record<string, any>> = {
91
+ /**
92
+ * Controls the text of the Multiselect button when multiple items selected, such as "All ___" or "2 ___"
93
+ * @default items
94
+ */
95
+ multipleItemsName?: string;
96
+ /**
97
+ * The visual variant of the Select. For multiselect, only `listbox` is supported.
98
+ * @default listbox
99
+ */
100
+ kind?: 'listbox',
101
+ /**
102
+ * Converts the single select into a multiselect.
103
+ */
104
+ multiple: true;
105
+ /**
106
+ * For multiselect, should be a string[] that matches the option IDs passed in the `options` prop. If `null`, no option will be selected.
107
+ */
108
+ value?: Option<T>['id'][] | null;
109
+ /**
110
+ * The interaction handler for the Select.
111
+ */
112
+ onChange?: (value: Option<T>['id'][] | null) => void | Promise<void>;
113
+ } & CommonSelectProps;
114
+
115
+ type SelectProps<T = Record<string, any>> =
116
+ | (SingleSelectProps<T>)
117
+ | (MultiSelectProps<T>);
118
+
86
119
  /**
87
120
  * A Select component is used to render a `select` box.
88
121
  *
@@ -112,10 +145,25 @@ export const Select = forwardRef(function <T = Record<string, any>>({
112
145
  kind = 'listbox',
113
146
  maxHeight = 320,
114
147
  hasOptionBorder = false,
148
+ multiple = false,
149
+ multipleItemsName,
115
150
  segmentedHeight = 'compact',
116
151
  overrides,
117
152
  }: SelectProps<T>, ref: ForwardedRef<any>) {
118
153
  const inputID = useId();
154
+ const multiItems = multipleItemsName || 'items';
155
+ const buttonText = () => {
156
+ if (!value || value.length === 0) {
157
+ return placeholder || 'Select an option';
158
+ } if (!multiple) {
159
+ return options?.find((o) => o.id === value)?.node;
160
+ } if (value && value.length === 1) {
161
+ return options?.find((o) => o.id === value[0])?.node;
162
+ } if (value.length === options.length) {
163
+ return `All ${multiItems}`;
164
+ }
165
+ return `${value.length} ${multiItems}`;
166
+ };
119
167
  return (
120
168
  <Field
121
169
  htmlFor={inputID}
@@ -137,6 +185,7 @@ export const Select = forwardRef(function <T = Record<string, any>>({
137
185
  ref={ref}
138
186
  value={value}
139
187
  onChange={onChange}
188
+ multiple={multiple}
140
189
  >
141
190
  <Listbox.Button
142
191
  id={inputID}
@@ -164,7 +213,7 @@ export const Select = forwardRef(function <T = Record<string, any>>({
164
213
  )}
165
214
  </div>
166
215
  )}
167
- {options?.find((o) => o.id === value)?.node || placeholder || 'Select an option'}
216
+ {buttonText()}
168
217
  {endEnhancer ? (
169
218
  <div
170
219
  {...overrides?.endEnhancerContainer}
@@ -184,13 +233,13 @@ export const Select = forwardRef(function <T = Record<string, any>>({
184
233
  </Listbox.Button>
185
234
  <Transition
186
235
  as="div"
236
+ className={dropdownStyles.transitionContainer}
187
237
  enter={dropdownStyles.transition}
188
238
  enterFrom={dropdownStyles.enterFrom}
189
239
  enterTo={dropdownStyles.enterTo}
190
240
  leave={dropdownStyles.transition}
191
241
  leaveFrom={dropdownStyles.leaveFrom}
192
242
  leaveTo={dropdownStyles.leaveTo}
193
- className={styles.transitionContainer}
194
243
  >
195
244
  <Listbox.Options
196
245
  className={clsx(
@@ -205,7 +254,7 @@ export const Select = forwardRef(function <T = Record<string, any>>({
205
254
  <Listbox.Option
206
255
  key={option.id}
207
256
  value={option.id}
208
- data-selected={option.id === value}
257
+ data-selected={option.id === value || (value && value.includes(option.id))}
209
258
  className={clsx(
210
259
  overrides?.option,
211
260
  styles.option,
@@ -218,7 +267,7 @@ export const Select = forwardRef(function <T = Record<string, any>>({
218
267
  {option.node}
219
268
  </Text>
220
269
  ) : option.node}
221
- {option.id === value && (
270
+ {(option.id === value || (value && value.includes(option.id))) && (
222
271
  <Icon icon={Check} size={12} />
223
272
  )}
224
273
  </Listbox.Option>
@@ -1,3 +1,8 @@
1
+ .transitionContainer {
2
+ position: relative;
3
+ z-index: 10;
4
+ }
5
+
1
6
  .transition {
2
7
  transition: var(--pte-animations-interaction);
3
8
  }