paris 0.13.6 → 0.14.1

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,24 @@
1
1
  # paris
2
2
 
3
+ ## 0.14.1
4
+
5
+ ### Patch Changes
6
+
7
+ - ffa4d0c: InformationalTooltip: Removed `headingIcon` prop
8
+ - ffa4d0c: InformationalTooltip: Opens onClick for mobile support, new `disableClick` prop to disable that behavior
9
+
10
+ ## 0.14.0
11
+
12
+ ### Minor Changes
13
+
14
+ - a805866: Select: added `multiple` prop to convert listbox into a multiselect
15
+
16
+ ### Patch Changes
17
+
18
+ - fa49d95: Select: updated hover state for segemented control
19
+ - fa49d95: InformationalTooltip: added `defaultOpen` prop, replacing previous `open` prop
20
+ - fa49d95: InformationalTooltip: added exit animation, and animations use duration theme variables
21
+
3
22
  ## 0.13.6
4
23
 
5
24
  ### 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.6",
5
+ "version": "0.14.1",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -20,5 +20,4 @@
20
20
  display: flex;
21
21
  align-items: center;
22
22
  gap: 8px;
23
- padding-left: 1px;
24
23
  }
@@ -1,7 +1,5 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { createElement } from 'react';
3
- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
4
- import { faPlus } from '@fortawesome/free-solid-svg-icons';
5
3
  import { InformationalTooltip } from './InformationalTooltip';
6
4
 
7
5
  const meta: Meta<typeof InformationalTooltip> = {
@@ -13,17 +11,15 @@ const meta: Meta<typeof InformationalTooltip> = {
13
11
  export default meta;
14
12
  type Story = StoryObj<typeof InformationalTooltip>;
15
13
 
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
- }));
14
+ const render: Story['render'] = (args) => createElement('div', {
15
+ style: { minHeight: '200px' },
16
+ }, createElement(InformationalTooltip, {
17
+ ...args,
18
+ }));
23
19
 
24
20
  export const Default: Story = {
25
21
  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. ',
22
+ 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
23
  heading: 'Quarterly taxes',
28
24
  },
29
25
  render,
@@ -51,15 +47,6 @@ export const CustomTrigger: Story = {
51
47
  render,
52
48
  };
53
49
 
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
50
  export const CustomAlign: Story = {
64
51
  args: {
65
52
  children: 'With some text below',
@@ -74,26 +61,3 @@ export const CustomAlign: Story = {
74
61
  },
75
62
  render,
76
63
  };
77
-
78
- export const HeadingIcon: Story = {
79
- args: {
80
- children: 'This is a medium tooltip with a heading',
81
- size: 'medium',
82
- headingIcon: (createElement(FontAwesomeIcon, {
83
- icon: faPlus,
84
- width: '14px',
85
- })),
86
- heading: 'Custom icon',
87
- },
88
- render,
89
- };
90
-
91
- export const NullIcon: Story = {
92
- args: {
93
- children: 'But the headingIcon is null',
94
- size: 'medium',
95
- headingIcon: null,
96
- heading: 'This has a heading',
97
- },
98
- render,
99
- };
@@ -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
  /**
@@ -18,8 +19,6 @@ export type InformationalTooltipProps = {
18
19
  trigger?: ReactNode;
19
20
  /** The heading text in the tooltip. If null, the heading will be hidden. */
20
21
  heading?: string | null;
21
- /** The heading icon in the tooltip. If undefined, will show info icon. If pass in an element, it will display in the heading. If set to null, will hide icon. */
22
- headingIcon?: ReactNode | null | undefined;
23
22
  /** The contents of the tooltip. */
24
23
  children?: ReactNode;
25
24
  /**
@@ -41,15 +40,15 @@ export type InformationalTooltipProps = {
41
40
  */
42
41
  align?: 'start' | 'center' | 'end';
43
42
  /**
44
- * The tooltip's open state.
43
+ * Whether the tooltip should be open by default.
44
+ * @default false
45
45
  */
46
- open?: boolean;
46
+ defaultOpen?: boolean;
47
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.
48
+ * By default, tooltip opens on hover and on click (for mobile support). If you want to disable the click event, set this to true.
49
+ * @default false
51
50
  */
52
- onOpenChange?: (value: boolean) => void | Promise<void>;
51
+ disableClick?: boolean;
53
52
  };
54
53
 
55
54
  /**
@@ -68,58 +67,92 @@ export const InformationalTooltip: FC<InformationalTooltipProps> = ({
68
67
  size = 'large',
69
68
  trigger,
70
69
  heading,
71
- headingIcon,
72
70
  children,
73
71
  side = 'bottom',
74
72
  sideOffset = 6,
75
73
  align = 'start',
76
- open,
77
- onOpenChange,
78
- }) => (
79
- <RadixTooltip.Provider
80
- delayDuration={150}
81
- >
82
- <RadixTooltip.Root
83
- open={open}
84
- onOpenChange={onOpenChange}
74
+ defaultOpen = false,
75
+ disableClick = false,
76
+ }) => {
77
+ const [isOpen, setOpen] = useState(defaultOpen);
78
+
79
+ /**
80
+ * Converts a CSS time value string to milliseconds
81
+ * @param timeValue - The CSS time value (e.g. '100ms', '0.5s')
82
+ * @returns The time value in milliseconds
83
+ * @throws Error if the time value format is invalid
84
+ */
85
+ const parseCSSTime = (timeValue: string): number => {
86
+ // Match the value and unit
87
+ const match = timeValue.match(/^([\d.]+)(ms|s)$/);
88
+
89
+ if (!match) {
90
+ console.warn('Invalid CSS time format. Expected formats: "100ms", "0.5s"');
91
+ return 0;
92
+ }
93
+
94
+ const [, value, unit] = match;
95
+ const numValue = parseFloat(value);
96
+
97
+ // Convert to milliseconds based on the unit
98
+ return unit === 's' ? numValue * 1000 : numValue;
99
+ };
100
+
101
+ return (
102
+ <RadixTooltip.Provider
103
+ delayDuration={parseCSSTime(pget('new.animations.duration.normal'))}
85
104
  >
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}
105
+ <RadixTooltip.Root
106
+ open={isOpen}
107
+ onOpenChange={setOpen}
108
+ >
109
+ <RadixTooltip.Trigger
110
+ onClick={() => {
111
+ if (!disableClick) {
112
+ setOpen(!isOpen);
113
+ }
114
+ }}
100
115
  >
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
- );
116
+ {!trigger ? (
117
+ <Icon icon={Info} size={14} className={styles.icon} style={{ color: pvar('new.colors.contentSecondary') }} />
118
+ ) : (
119
+ <>
120
+ {trigger}
121
+ </>
122
+ )}
123
+ </RadixTooltip.Trigger>
124
+ <AnimatePresence>
125
+ {isOpen && (
126
+ <RadixTooltip.Portal forceMount>
127
+ <RadixTooltip.Content
128
+ side={side}
129
+ sideOffset={sideOffset}
130
+ align={align}
131
+ asChild
132
+ >
133
+ <motion.div
134
+ initial={{ opacity: 0, y: 3 }}
135
+ animate={{ opacity: 1, y: 0 }}
136
+ exit={{ opacity: 0, y: 3 }}
137
+ transition={{ duration: parseCSSTime(pget('new.animations.duration.normal')) / 1000 }}
138
+ className={clsx(styles.tooltip, styles[size])}
139
+ >
140
+ {heading && (
141
+ <div className={styles.heading}>
142
+ <TextWhenString as="p" kind="paragraphXSmall" weight="medium">
143
+ {heading}
144
+ </TextWhenString>
145
+ </div>
146
+ )}
147
+ <TextWhenString as="p" kind="paragraphXSmall">
148
+ {children}
149
+ </TextWhenString>
150
+ </motion.div>
151
+ </RadixTooltip.Content>
152
+ </RadixTooltip.Portal>
153
+ )}
154
+ </AnimatePresence>
155
+ </RadixTooltip.Root>
156
+ </RadixTooltip.Provider>
157
+ );
158
+ };
@@ -281,8 +281,7 @@
281
281
  }
282
282
 
283
283
  &:hover {
284
- background-color: var(--pte-new-colors-surfaceSecondary);
285
- color: var(--pte-new-colors-contentSecondary);
284
+ color: var(--pte-new-colors-contentPrimary);
286
285
  }
287
286
 
288
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}
@@ -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>