paris 0.17.5 → 0.17.7

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.17.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 188ed3e: AccordionSelect: new component for expandable selection groups
8
+ CardButton: added `kind` variants (`raised`, `surface`, `flat`) and `status` prop; removed SelectableCard
9
+ Drawer: added `bottomPanelPadding` prop for edge-to-edge footer layouts
10
+ - 188ed3e: Checkbox: removed background hover from `panel` variant
11
+ - 188ed3e: Drawer: removed padding from `bottomPanel` so footer elements can stretch edge-to-edge
12
+ Combobox: fixed stale options showing on re-focus by resetting query state on blur
13
+ Field: custom component labels now receive consistent styling with string labels
14
+ Dialog: `width` prop now accepts custom CSS lengths in addition to presets
15
+
16
+ ## 0.17.6
17
+
18
+ ### Patch Changes
19
+
20
+ - c42222a: Fix CSS variable reference
21
+
3
22
  ## 0.17.5
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.17.5",
5
+ "version": "0.17.7",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -33,6 +33,7 @@
33
33
  "exports": {
34
34
  "./*": "./src/stories/*",
35
35
  "./accordion": "./src/stories/accordion/index.ts",
36
+ "./accordionselect": "./src/stories/accordionselect/index.ts",
36
37
  "./avatar": "./src/stories/avatar/index.ts",
37
38
  "./button": "./src/stories/button/index.ts",
38
39
  "./callout": "./src/stories/callout/index.ts",
@@ -0,0 +1,103 @@
1
+ .root {
2
+ color: var(--pte-new-colors-contentPrimary);
3
+ border: 1px solid var(--pte-new-colors-borderStrong);
4
+ border-radius: var(--pte-new-borders-radius-roundedMedium);
5
+ background: var(--pte-new-colors-surfacePrimary);
6
+ overflow: hidden;
7
+ transition: var(--pte-animations-interaction);
8
+
9
+ &.open {
10
+ border-color: var(--pte-new-colors-borderUltrastrong);
11
+ }
12
+ }
13
+
14
+ .header {
15
+ padding: 10px 12px;
16
+ display: flex;
17
+ justify-content: space-between;
18
+ align-items: center;
19
+ gap: 10px;
20
+ background-color: var(--pte-new-colors-overlayWhiteSubtle);
21
+ cursor: pointer;
22
+ transition: var(--pte-animations-interaction);
23
+
24
+ &.open {
25
+ background-color: var(--pte-new-colors-overlayMedium);
26
+ border-bottom: 1px solid var(--pte-new-colors-borderStrong);
27
+ }
28
+
29
+ &:hover {
30
+ background-color: var(--pte-new-colors-overlayStrong);
31
+ }
32
+ }
33
+
34
+ .headerContent {
35
+ flex: 1;
36
+ min-width: 0;
37
+ }
38
+
39
+ .headerEnd {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 8px;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .chevron {
47
+ transition: transform var(--pte-animations-duration-gradual) var(--pte-animations-timing-easeInOutExpo);
48
+ transform: rotate(90deg);
49
+
50
+ &.open {
51
+ transform: rotate(-90deg);
52
+ }
53
+ }
54
+
55
+ .dropdown {
56
+ overflow: hidden;
57
+ }
58
+
59
+ .dropdownContent {
60
+ display: flex;
61
+ flex-direction: column;
62
+ padding: 0;
63
+ gap: 0;
64
+ }
65
+
66
+ .option {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 10px;
70
+ padding: 8px 14px;
71
+ border-bottom: 0.5px solid var(--pte-new-colors-borderMedium);
72
+ background: transparent;
73
+ color: inherit;
74
+ cursor: pointer;
75
+ transition: background var(--pte-animations-duration-instant) var(--pte-animations-timing-easeInOutExpo);
76
+
77
+ &:last-of-type {
78
+ border-bottom: none;
79
+ }
80
+
81
+ &[data-selected='true'] {
82
+ background: var(--pte-new-colors-overlaySubtle);
83
+ }
84
+
85
+ &:hover {
86
+ background: var(--pte-new-colors-overlayMedium);
87
+ }
88
+
89
+ &:disabled {
90
+ opacity: 0.4;
91
+ cursor: not-allowed;
92
+
93
+ &:hover {
94
+ background: transparent;
95
+ }
96
+ }
97
+ }
98
+
99
+ .optionContent {
100
+ flex: 1;
101
+ text-align: left;
102
+ min-width: 0;
103
+ }
@@ -0,0 +1,41 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { AccordionSelect } from './AccordionSelect';
3
+
4
+ const meta: Meta<typeof AccordionSelect> = {
5
+ title: 'Inputs/AccordionSelect',
6
+ component: AccordionSelect,
7
+ tags: ['autodocs'],
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof AccordionSelect>;
12
+
13
+ const options = [
14
+ { id: 'champagne', node: 'In an alleyway, drinking champagne' },
15
+ { id: 'rooftop', node: 'On a rooftop, watching the sunset' },
16
+ { id: 'garden', node: 'In a garden, under the stars' },
17
+ ];
18
+
19
+ export const Default: Story = {
20
+ args: {
21
+ options,
22
+ value: 'champagne',
23
+ },
24
+ };
25
+
26
+ export const NoSelection: Story = {
27
+ args: {
28
+ options,
29
+ placeholder: 'Where were we?',
30
+ },
31
+ };
32
+
33
+ export const WithDisabledOption: Story = {
34
+ args: {
35
+ options: [
36
+ ...options,
37
+ { id: 'nowhere', node: 'Nowhere, it was all a dream', disabled: true },
38
+ ],
39
+ value: 'champagne',
40
+ },
41
+ };
@@ -0,0 +1,268 @@
1
+ 'use client';
2
+
3
+ import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
4
+ import {
5
+ useCallback, useEffect, useRef, useState,
6
+ } from 'react';
7
+ import { AnimatePresence, motion } from 'framer-motion';
8
+ import { clsx } from 'clsx';
9
+ import { Check, ChevronRight, Icon } from '../icon';
10
+ import { TextWhenString } from '../utility';
11
+ import styles from './AccordionSelect.module.scss';
12
+
13
+ export type AccordionSelectOption<T = Record<string, unknown>> = {
14
+ /**
15
+ * A unique identifier for the option.
16
+ */
17
+ id: string;
18
+ /**
19
+ * The content to render for this option.
20
+ */
21
+ node: ReactNode;
22
+ /**
23
+ * Whether this option is disabled.
24
+ * @default false
25
+ */
26
+ disabled?: boolean;
27
+ /**
28
+ * Optional metadata associated with the option.
29
+ */
30
+ metadata?: T;
31
+ };
32
+
33
+ export type AccordionSelectProps<T = Record<string, unknown>> = {
34
+ /**
35
+ * The list of selectable options.
36
+ */
37
+ options: AccordionSelectOption<T>[];
38
+ /**
39
+ * The currently selected option ID.
40
+ */
41
+ value?: string | null;
42
+ /**
43
+ * Called when the user selects an option.
44
+ */
45
+ onChange?: (option: AccordionSelectOption<T>) => void;
46
+ /**
47
+ * Custom content to render as the header when an option is selected.
48
+ * Receives the selected option. If not provided, the option's `node` is used.
49
+ */
50
+ renderSelected?: (option: AccordionSelectOption<T>) => ReactNode;
51
+ /**
52
+ * Custom content to render for each option in the dropdown.
53
+ * Receives the option and whether it's selected. If not provided, the option's `node` is used.
54
+ */
55
+ renderOption?: (option: AccordionSelectOption<T>, isSelected: boolean) => ReactNode;
56
+ /**
57
+ * Placeholder to show when no option is selected.
58
+ * @default 'Select an option'
59
+ */
60
+ placeholder?: ReactNode;
61
+ /**
62
+ * Whether the accordion is open. If provided, the component becomes controlled.
63
+ */
64
+ isOpen?: boolean;
65
+ /**
66
+ * Called when the open state changes.
67
+ */
68
+ onOpenChange?: (open: boolean) => void;
69
+ /**
70
+ * Whether to close the accordion when an option is selected.
71
+ * @default true
72
+ */
73
+ closeOnSelect?: boolean;
74
+ /**
75
+ * Whether to close the accordion when clicking outside.
76
+ * @default true
77
+ */
78
+ closeOnClickOutside?: boolean;
79
+ /**
80
+ * Optional action to render at the bottom of the options list (e.g. "Add new" button).
81
+ */
82
+ action?: ReactNode;
83
+ /**
84
+ * Optional label to display on the header (e.g. a Tag).
85
+ */
86
+ label?: ReactNode;
87
+ /**
88
+ * Optional overrides for nested components.
89
+ */
90
+ overrides?: {
91
+ root?: ComponentPropsWithoutRef<'div'>;
92
+ header?: ComponentPropsWithoutRef<'div'>;
93
+ dropdown?: ComponentPropsWithoutRef<'div'>;
94
+ dropdownContent?: ComponentPropsWithoutRef<'div'>;
95
+ option?: ComponentPropsWithoutRef<'button'>;
96
+ };
97
+ };
98
+
99
+ /**
100
+ * An AccordionSelect component. Displays the selected option in a card header that expands to reveal all options.
101
+ *
102
+ * <hr />
103
+ *
104
+ * To use this component, import it as follows:
105
+ *
106
+ * ```js
107
+ * import { AccordionSelect } from 'paris/accordionselect';
108
+ * ```
109
+ * @constructor
110
+ */
111
+ export const AccordionSelect: FC<AccordionSelectProps> = ({
112
+ options,
113
+ value,
114
+ onChange,
115
+ renderSelected,
116
+ renderOption,
117
+ placeholder = 'Select an option',
118
+ isOpen: controlledOpen,
119
+ onOpenChange,
120
+ closeOnSelect = true,
121
+ closeOnClickOutside = true,
122
+ action,
123
+ label,
124
+ overrides,
125
+ }) => {
126
+ const [internalOpen, setInternalOpen] = useState(false);
127
+ const open = controlledOpen ?? internalOpen;
128
+ const rootRef = useRef<HTMLDivElement>(null);
129
+
130
+ const setOpen = useCallback((nextOpen: boolean) => {
131
+ setInternalOpen(nextOpen);
132
+ onOpenChange?.(nextOpen);
133
+ }, [onOpenChange]);
134
+
135
+ const selectedOption = options.find((o) => o.id === value);
136
+
137
+ useEffect(() => {
138
+ if (!closeOnClickOutside || !open) {
139
+ return () => {};
140
+ }
141
+
142
+ const handleClickOutside = (e: MouseEvent) => {
143
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
144
+ setOpen(false);
145
+ }
146
+ };
147
+
148
+ document.addEventListener('mousedown', handleClickOutside);
149
+ return () => document.removeEventListener('mousedown', handleClickOutside);
150
+ }, [closeOnClickOutside, open, setOpen]);
151
+
152
+ let headerContent: ReactNode = placeholder;
153
+ if (selectedOption) {
154
+ headerContent = renderSelected
155
+ ? renderSelected(selectedOption)
156
+ : selectedOption.node;
157
+ }
158
+
159
+ return (
160
+ <div
161
+ ref={rootRef}
162
+ {...overrides?.root}
163
+ className={clsx(
164
+ styles.root,
165
+ open && styles.open,
166
+ overrides?.root?.className,
167
+ )}
168
+ >
169
+ <div
170
+ {...overrides?.header}
171
+ className={clsx(
172
+ styles.header,
173
+ open && styles.open,
174
+ overrides?.header?.className,
175
+ )}
176
+ onClick={() => setOpen(!open)}
177
+ onKeyDown={(e) => {
178
+ if (e.key === 'Enter' || e.key === ' ') {
179
+ e.preventDefault();
180
+ setOpen(!open);
181
+ }
182
+ }}
183
+ role="button"
184
+ tabIndex={0}
185
+ >
186
+ <div className={styles.headerContent}>
187
+ <TextWhenString kind="paragraphSmall" weight="medium">
188
+ {headerContent}
189
+ </TextWhenString>
190
+ </div>
191
+ <div className={styles.headerEnd}>
192
+ {label}
193
+ <Icon
194
+ icon={ChevronRight}
195
+ size={16}
196
+ className={clsx(styles.chevron, open && styles.open)}
197
+ />
198
+ </div>
199
+ </div>
200
+
201
+ <AnimatePresence>
202
+ {open && (
203
+ <motion.div
204
+ key="content"
205
+ initial="collapsed"
206
+ animate="open"
207
+ exit="collapsed"
208
+ variants={{
209
+ open: { opacity: 1, height: 'auto' },
210
+ collapsed: { opacity: 0, height: 0 },
211
+ }}
212
+ transition={{
213
+ duration: 0.8,
214
+ ease: [0.87, 0, 0.13, 1],
215
+ }}
216
+ className={clsx(
217
+ styles.dropdown,
218
+ overrides?.dropdown?.className,
219
+ )}
220
+ >
221
+ <div
222
+ {...overrides?.dropdownContent}
223
+ className={clsx(
224
+ styles.dropdownContent,
225
+ overrides?.dropdownContent?.className,
226
+ )}
227
+ >
228
+ {options.map((option) => {
229
+ const isOptionSelected = option.id === value;
230
+ return (
231
+ <button
232
+ key={option.id}
233
+ type="button"
234
+ disabled={option.disabled}
235
+ data-selected={isOptionSelected}
236
+ {...overrides?.option}
237
+ className={clsx(
238
+ styles.option,
239
+ overrides?.option?.className,
240
+ )}
241
+ onClick={() => {
242
+ onChange?.(option);
243
+ if (closeOnSelect) setOpen(false);
244
+ }}
245
+ >
246
+ <div className={styles.optionContent}>
247
+ {renderOption
248
+ ? renderOption(option, isOptionSelected)
249
+ : (
250
+ <TextWhenString kind="paragraphXSmall" weight="medium">
251
+ {option.node}
252
+ </TextWhenString>
253
+ )}
254
+ </div>
255
+ {isOptionSelected && (
256
+ <Icon icon={Check} size={13} />
257
+ )}
258
+ </button>
259
+ );
260
+ })}
261
+ {action}
262
+ </div>
263
+ </motion.div>
264
+ )}
265
+ </AnimatePresence>
266
+ </div>
267
+ );
268
+ };
@@ -0,0 +1 @@
1
+ export * from './AccordionSelect';
@@ -45,7 +45,7 @@
45
45
  border-color: var(--pte-new-colors-buttonFill);
46
46
 
47
47
  &:hover {
48
- border-color: var(-pte-new-colors-buttonFillHover);
48
+ border-color: var(--pte-new-colors-buttonFillHover);
49
49
  }
50
50
 
51
51
  &[aria-disabled=true] {
@@ -115,9 +115,9 @@
115
115
  color: var(--pte-new-colors-contentDisabled);
116
116
  }
117
117
 
118
- &:hover {
119
- background-color: var(--pte-new-colors-overlaySubtle);
120
- }
118
+ //&:hover {
119
+ // background-color: var(--pte-new-colors-overlaySubtle);
120
+ //}
121
121
 
122
122
  .box {
123
123
  width: 13px;
@@ -249,6 +249,11 @@ export function Combobox<T extends Record<string, any> = Record<string, any>>({
249
249
  });
250
250
  }
251
251
  }}
252
+ onBlur={(e) => {
253
+ setQuery('');
254
+ if (onInputChange) onInputChange('');
255
+ if (overrides?.input?.onBlur) overrides.input.onBlur(e);
256
+ }}
252
257
  aria-disabled={disabled}
253
258
  data-status={disabled ? 'disabled' : (status || 'default')}
254
259
  className={clsx(
@@ -3,7 +3,8 @@
3
3
  import type {
4
4
  ComponentPropsWithoutRef, FC, MouseEventHandler, PropsWithChildren, ReactNode,
5
5
  } from 'react';
6
- import { useEffect, useState } from 'react';
6
+ import { useEffect, useMemo, useState } from 'react';
7
+ import type { CSSLength } from '@ssh/csstypes';
7
8
  import {
8
9
  Dialog as HDialog, DialogPanel, DialogTitle, Transition, TransitionChild,
9
10
  } from '@headlessui/react';
@@ -16,6 +17,8 @@ import { VisuallyHidden } from '../utility/VisuallyHidden';
16
17
  import { RemoveFromDOM } from '../utility/RemoveFromDOM';
17
18
  import { Close, Icon } from '../icon';
18
19
 
20
+ export const DialogWidthPresets = ['compact', 'default', 'large', 'full'] as const;
21
+
19
22
  export type DialogProps = {
20
23
  /**
21
24
  * The dialog's open state.
@@ -47,11 +50,12 @@ export type DialogProps = {
47
50
  */
48
51
  hideCloseButton?: boolean;
49
52
  /**
50
- * The width of the dialog.
53
+ * The width of the dialog. Either a preset or a valid {@link CSSLength} string.
51
54
  *
55
+ * @see DialogWidthPresets
52
56
  * @default 'default'
53
57
  */
54
- width?: 'compact' | 'default' | 'large' | 'full';
58
+ width?: typeof DialogWidthPresets[number] | CSSLength;
55
59
  /**
56
60
  * The height of the dialog.
57
61
  *
@@ -129,6 +133,8 @@ export const Dialog: FC<PropsWithChildren<DialogProps>> = ({
129
133
  overlayStyle = 'blur',
130
134
  children,
131
135
  }) => {
136
+ const widthIsPreset = useMemo(() => (DialogWidthPresets as readonly string[]).includes(width), [width]);
137
+
132
138
  const [dragging, setDragging] = useState(false);
133
139
  const [position, setPosition] = useState({ top: 0, left: 0 });
134
140
  const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
@@ -228,11 +234,16 @@ export const Dialog: FC<PropsWithChildren<DialogProps>> = ({
228
234
  className={clsx(
229
235
  styles.panel,
230
236
  styles[appearance],
231
- styles[`w-${width}`],
237
+ { [styles[`w-${width}`]]: widthIsPreset },
232
238
  styles[`h-${height}`],
233
239
  overrides.panel?.className,
234
240
  )}
235
- style={{ top: `${position.top}px`, left: `${position.left}px` }}
241
+ style={{
242
+ top: `${position.top}px`,
243
+ left: `${position.left}px`,
244
+ ...(!widthIsPreset ? { maxWidth: width } : {}),
245
+ ...overrides.panel?.style,
246
+ }}
236
247
  onMouseDown={handleMouseDown}
237
248
  onMouseUp={handleMouseUp}
238
249
  onMouseMove={handleMouseMove}
@@ -415,6 +415,10 @@ $panelAnimationDelay: var(--pte-animations-duration-fast);
415
415
  .bottomPanelContent {
416
416
  position: relative;
417
417
  padding: 20px;
418
+
419
+ &.noPadding {
420
+ padding: 0;
421
+ }
418
422
  }
419
423
 
420
424
  .bottomPanelSpacer {
@@ -239,6 +239,67 @@ export const BottomPanel: Story = {
239
239
  },
240
240
  };
241
241
 
242
+ export const BottomPanelMultiSection: Story = {
243
+ args: {
244
+ title: 'Order summary',
245
+ children: (
246
+ <div style={{
247
+ width: '100%', display: 'flex', flexDirection: 'column', gap: '12px',
248
+ }}
249
+ >
250
+ <p>Review your order before confirming.</p>
251
+ </div>
252
+ ),
253
+ bottomPanelPadding: false,
254
+ bottomPanel: (
255
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
256
+ <div style={{
257
+ display: 'flex',
258
+ justifyContent: 'space-between',
259
+ padding: '12px 20px',
260
+ borderBottom: '1px solid var(--pte-new-colors-borderMedium)',
261
+ background: 'var(--pte-new-colors-overlaySubtle)',
262
+ }}
263
+ >
264
+ <span>Total</span>
265
+ <strong>$249.00</strong>
266
+ </div>
267
+ <div style={{
268
+ display: 'flex',
269
+ flexDirection: 'column',
270
+ gap: '12px',
271
+ padding: '20px',
272
+ }}
273
+ >
274
+ <Button>
275
+ Confirm order
276
+ </Button>
277
+ <Button kind="secondary" theme="negative">
278
+ Cancel
279
+ </Button>
280
+ </div>
281
+ </div>
282
+ ),
283
+ },
284
+ render: function Render(args) {
285
+ const [isOpen, setIsOpen] = useState(false);
286
+ return (
287
+ <>
288
+ <Button onClick={() => setIsOpen(true)}>
289
+ Review order
290
+ </Button>
291
+ <Drawer
292
+ {...args}
293
+ isOpen={isOpen}
294
+ onClose={setIsOpen}
295
+ >
296
+ {args.children}
297
+ </Drawer>
298
+ </>
299
+ );
300
+ },
301
+ };
302
+
242
303
  export const Full: Story = {
243
304
  args: {
244
305
  title: 'Transaction details',
@@ -62,6 +62,12 @@ export type DrawerProps<T extends string[] | readonly string[] = string[]> = {
62
62
  * An optional panel that will be rendered at the bottom of the Drawer. This is useful for adding a footer to the Drawer with actions.
63
63
  */
64
64
  bottomPanel?: ReactNode;
65
+ /**
66
+ * Whether the bottom panel should have default padding. Set to `false` for edge-to-edge content like dividers or multi-section layouts.
67
+ *
68
+ * @default true
69
+ */
70
+ bottomPanelPadding?: boolean;
65
71
 
66
72
  /**
67
73
  * An optional area that will be rendered at the top of the Drawer next to the title. This is useful for adding actions to the Drawer. Recommended to use {@link Menu} for an action menu.
@@ -149,6 +155,7 @@ export const Drawer = <T extends string[] | readonly string[] = string[]>({
149
155
  hideTitle = false,
150
156
  hideCloseButton = false,
151
157
  bottomPanel,
158
+ bottomPanelPadding = true,
152
159
  from = 'right',
153
160
  size = 'default',
154
161
  pagination,
@@ -382,7 +389,7 @@ export const Drawer = <T extends string[] | readonly string[] = string[]>({
382
389
  <div className={clsx(styles.bottomPanel, overrides?.bottomPanel?.className)}>
383
390
  <div className={styles.glassOpacity} />
384
391
  <div className={styles.glassBlend} />
385
- <div className={clsx(styles.bottomPanelContent, overrides?.bottomPanelContent?.className)}>
392
+ <div className={clsx(styles.bottomPanelContent, { [styles.noPadding]: !bottomPanelPadding }, overrides?.bottomPanelContent?.className)}>
386
393
  {bottomPanel}
387
394
  </div>
388
395
  </div>
@@ -80,7 +80,7 @@ export const Field: FC<PropsWithChildren<FieldProps>> = ({
80
80
  </Text>
81
81
  )
82
82
  : (
83
- <label htmlFor={htmlFor} className={clsx({ [styles.hidden]: props.hideLabel })}>
83
+ <label htmlFor={htmlFor} className={clsx(styles.label, { [styles.hidden]: props.hideLabel })}>
84
84
  {props.label}
85
85
  </label>
86
86
  );