paris 0.22.1 → 0.22.2

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,40 @@
1
1
  # paris
2
2
 
3
+ ## 0.22.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 5173ecf: Fix scroll snap when collapsing `Accordion` and `AccordionSelect` inside a scrollable parent.
8
+
9
+ When the dropdown was opened, the user scrolled past it, and then closed it,
10
+ the scrollable ancestor would instantly snap to the position it would occupy
11
+ once the dropdown was fully closed — while the visual collapse animation was
12
+ still running. The cause was framer-motion's `height: 'auto' → 0` exit
13
+ animation thrashing the layout in the first paint frame, which the browser
14
+ responded to by clamping `scrollTop`.
15
+
16
+ The collapse animation now uses the CSS grid-rows trick (`grid-template-rows:
17
+ 1fr` → `0fr`) instead. Layout stays stable across the entire transition, so
18
+ `scrollTop` clamps smoothly in step with the animation. Duration and easing
19
+ match the previous behavior (800ms, `cubic-bezier(0.87, 0, 0.13, 1)`).
20
+
21
+ One small behavior change: collapsed content remains in the DOM (it was
22
+ previously unmounted by `AnimatePresence`). The container is marked
23
+ `aria-hidden` when closed and option buttons receive `tabIndex={-1}`, so
24
+ screen readers and keyboard navigation continue to skip hidden content.
25
+
26
+ - 5173ecf: Update runtime dependencies to current semver-compatible versions and patch
27
+ security advisories. Notable bumps: Tiptap 3.22 → 3.23, Framer Motion 12.24
28
+ → 12.40, Headless UI 2.2.4 → 2.2.10, Ariakit 0.4.20 → 0.4.28, react-hot-toast
29
+ 2.4 → 2.6, lucide-react 1.7 → 1.16, ts-deepmerge 6.0 → 6.2. No API changes
30
+ expected, but consumers will pick up the newer transitives on install.
31
+ - 5173ecf: `<Text fontStyle="italic">` is now reliably italic. Mirror the pattern already
32
+ used by weight classes and apply `!important` to `.fontStyle-*` rules.
33
+ Without it, the per-kind typography classes (e.g. `.paragraphSmall { font-style: normal }`,
34
+ emitted when consumers define per-style `font-style` theme variables) win
35
+ over `.fontStyle-italic` and suppress italic on `<Text>` and anything that
36
+ delegates to it — notably `<Markdown>` rendering `<em>`.
37
+
3
38
  ## 0.22.1
4
39
 
5
40
  ### 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.22.1",
5
+ "version": "0.22.2",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -85,38 +85,38 @@
85
85
  "./utility": "./src/stories/utility/index.ts"
86
86
  },
87
87
  "dependencies": {
88
- "@ariakit/react": "^0.4.20",
88
+ "@ariakit/react": "^0.4.28",
89
89
  "@emotion/is-prop-valid": "^1.4.0",
90
90
  "@fortawesome/fontawesome-svg-core": "^6.7.2",
91
91
  "@fortawesome/free-regular-svg-icons": "^6.7.2",
92
92
  "@fortawesome/free-solid-svg-icons": "^6.7.2",
93
- "@fortawesome/react-fontawesome": "^0.2.2",
94
- "@headlessui/react": "^2.2.4",
93
+ "@fortawesome/react-fontawesome": "^0.2.6",
94
+ "@headlessui/react": "^2.2.10",
95
95
  "@radix-ui/react-checkbox": "^1.3.3",
96
96
  "@radix-ui/react-tooltip": "^1.2.8",
97
- "@tiptap/extension-image": "^3.22.2",
98
- "@tiptap/extension-link": "^3.22.2",
99
- "@tiptap/extension-placeholder": "^3.22.2",
100
- "@tiptap/extension-table": "^3.22.2",
101
- "@tiptap/extension-table-cell": "^3.22.2",
102
- "@tiptap/extension-table-header": "^3.22.2",
103
- "@tiptap/extension-table-row": "^3.22.2",
104
- "@tiptap/extension-task-item": "^3.22.2",
105
- "@tiptap/extension-task-list": "^3.22.2",
106
- "@tiptap/markdown": "^3.22.2",
107
- "@tiptap/react": "^3.22.2",
108
- "@tiptap/starter-kit": "^3.22.2",
97
+ "@tiptap/extension-image": "^3.23.6",
98
+ "@tiptap/extension-link": "^3.23.6",
99
+ "@tiptap/extension-placeholder": "^3.23.6",
100
+ "@tiptap/extension-table": "^3.23.6",
101
+ "@tiptap/extension-table-cell": "^3.23.6",
102
+ "@tiptap/extension-table-header": "^3.23.6",
103
+ "@tiptap/extension-table-row": "^3.23.6",
104
+ "@tiptap/extension-task-item": "^3.23.6",
105
+ "@tiptap/extension-task-list": "^3.23.6",
106
+ "@tiptap/markdown": "^3.23.6",
107
+ "@tiptap/react": "^3.23.6",
108
+ "@tiptap/starter-kit": "^3.23.6",
109
109
  "clsx": "^1.2.1",
110
110
  "font-color-contrast": "^11.1.0",
111
- "framer-motion": "^12.24.10",
112
- "lucide-react": "^1.7.0",
111
+ "framer-motion": "^12.40.0",
112
+ "lucide-react": "^1.16.0",
113
113
  "pte": "^0.5.0",
114
- "react-hot-toast": "^2.4.1",
114
+ "react-hot-toast": "^2.6.0",
115
115
  "react-markdown": "^10.1.0",
116
116
  "react-tiny-popover": "^8.1.6",
117
117
  "rehype-raw": "^7.0.0",
118
118
  "remark-gfm": "^4.0.1",
119
- "ts-deepmerge": "^6.0.3"
119
+ "ts-deepmerge": "^6.2.1"
120
120
  },
121
121
  "peerDependencies": {
122
122
  "@types/react": "^19",
@@ -127,10 +127,10 @@
127
127
  "typescript": "^5.0"
128
128
  },
129
129
  "devDependencies": {
130
- "@biomejs/biome": "^2.0.6",
131
- "@changesets/cli": "^2.26.1",
132
- "@commitlint/cli": "^19.8.1",
133
- "@commitlint/config-conventional": "^19.8.1",
130
+ "@biomejs/biome": "^2.4.15",
131
+ "@changesets/cli": "^2.31.0",
132
+ "@commitlint/cli": "^21",
133
+ "@commitlint/config-conventional": "^21",
134
134
  "@ssh/csstypes": "^1.1.0",
135
135
  "@storybook/addon-docs": "10.3.4",
136
136
  "@storybook/addon-links": "10.3.4",
@@ -139,32 +139,31 @@
139
139
  "@testing-library/jest-dom": "^6.9.1",
140
140
  "@testing-library/react": "^16.3.2",
141
141
  "@testing-library/user-event": "^14.6.1",
142
- "@types/node": "^22.0.0",
143
- "@types/react": "^19",
144
- "@types/react-dom": "^19",
142
+ "@types/node": "^22.19.19",
143
+ "@types/react": "^19.2.15",
144
+ "@types/react-dom": "^19.2.3",
145
145
  "@vitest/browser-playwright": "4.1.2",
146
- "@vitest/coverage-v8": "^4.1.2",
147
- "autoprefixer": "^10.4.14",
146
+ "@vitest/coverage-v8": "^4.1.7",
147
+ "autoprefixer": "^10.5.0",
148
148
  "change-case": "^4.1.2",
149
- "csstype": "^3.1.2",
150
- "esbuild-sass-plugin": "^2.16.0",
151
- "jsdom": "^29.0.1",
149
+ "csstype": "^3.2.3",
150
+ "jsdom": "^29.1.1",
152
151
  "jss": "^10.10.0",
153
152
  "jss-preset-default": "^10.10.0",
154
- "lefthook": "^1.11.13",
155
- "next": "^16.2.2",
156
- "playwright": "^1.59.1",
157
- "react": "^19.0.0",
158
- "react-dom": "^19.0.0",
159
- "sass": "^1.62.1",
153
+ "lefthook": "^1.13.6",
154
+ "next": "^16.2.6",
155
+ "playwright": "^1.60.0",
156
+ "react": "^19.2.6",
157
+ "react-dom": "^19.2.6",
158
+ "sass": "^1.100.0",
160
159
  "storybook": "10.3.4",
161
160
  "storybook-dark-mode": "^5.0.0",
162
161
  "title-case": "^3.0.3",
163
- "ts-node": "^10.9.1",
164
- "tsup": "^7.2.0",
165
- "type-fest": "^3.10.0",
166
- "typescript": "^5.2.2",
167
- "vite": "^7.0.0",
168
- "vitest": "^4.1.2"
162
+ "ts-node": "^10.9.2",
163
+ "tsup": "^8",
164
+ "type-fest": "^3.13.1",
165
+ "typescript": "^5.9.3",
166
+ "vite": "^7.3.3",
167
+ "vitest": "^4.1.7"
169
168
  }
170
169
  }
@@ -39,8 +39,23 @@
39
39
  }
40
40
 
41
41
  .dropdown {
42
- overflow: hidden;
42
+ display: grid;
43
+ grid-template-rows: 0fr;
44
+ opacity: 0;
43
45
  cursor: auto;
46
+ transition:
47
+ grid-template-rows 800ms cubic-bezier(0.87, 0, 0.13, 1),
48
+ opacity 800ms cubic-bezier(0.87, 0, 0.13, 1);
49
+
50
+ &.open {
51
+ grid-template-rows: 1fr;
52
+ opacity: 1;
53
+ }
54
+ }
55
+
56
+ .dropdownClip {
57
+ min-height: 0;
58
+ overflow: hidden;
44
59
  }
45
60
 
46
61
  .dropdownContent {
@@ -100,8 +115,23 @@
100
115
  }
101
116
 
102
117
  .dropdown {
103
- overflow: hidden;
118
+ display: grid;
119
+ grid-template-rows: 0fr;
120
+ opacity: 0;
104
121
  cursor: auto;
122
+ transition:
123
+ grid-template-rows 800ms cubic-bezier(0.87, 0, 0.13, 1),
124
+ opacity 800ms cubic-bezier(0.87, 0, 0.13, 1);
125
+
126
+ &.open {
127
+ grid-template-rows: 1fr;
128
+ opacity: 1;
129
+ }
130
+ }
131
+
132
+ .dropdownClip {
133
+ min-height: 0;
134
+ overflow: hidden;
105
135
  }
106
136
 
107
137
  .dropdownContent {
@@ -9,7 +9,9 @@ describe('Accordion', () => {
9
9
 
10
10
  it('does not show children when collapsed', () => {
11
11
  render(<Accordion title="Title">Hidden content</Accordion>);
12
- expect(screen.queryByText('Hidden content')).not.toBeInTheDocument();
12
+ // Content is in the DOM but hidden via aria-hidden on the collapsed dropdown
13
+ const content = screen.getByText('Hidden content');
14
+ expect(content.closest('[aria-hidden]')).toHaveAttribute('aria-hidden', 'true');
13
15
  });
14
16
 
15
17
  it('expands on click to reveal children', async () => {
@@ -26,9 +28,9 @@ describe('Accordion', () => {
26
28
  expect(screen.getByText('Toggle content')).toBeInTheDocument();
27
29
 
28
30
  await user.click(button);
29
- // AnimatePresence exit animation may keep element mounted briefly
30
31
  await waitFor(() => {
31
- expect(screen.queryByText('Toggle content')).not.toBeInTheDocument();
32
+ const content = screen.getByText('Toggle content');
33
+ expect(content.closest('[aria-hidden]')).toHaveAttribute('aria-hidden', 'true');
32
34
  });
33
35
  });
34
36
 
@@ -69,7 +71,7 @@ describe('Accordion', () => {
69
71
  </Accordion>,
70
72
  );
71
73
 
72
- expect(screen.queryByText('Controlled content')).not.toBeInTheDocument();
74
+ expect(screen.getByText('Controlled content').closest('[aria-hidden]')).toHaveAttribute('aria-hidden', 'true');
73
75
 
74
76
  // Open externally
75
77
  rerender(
@@ -77,7 +79,7 @@ describe('Accordion', () => {
77
79
  Controlled content
78
80
  </Accordion>,
79
81
  );
80
- expect(screen.getByText('Controlled content')).toBeInTheDocument();
82
+ expect(screen.getByText('Controlled content').closest('[aria-hidden]')).toHaveAttribute('aria-hidden', 'false');
81
83
 
82
84
  // Click should call onOpenChange but not change state (controlled)
83
85
  await user.click(screen.getByRole('button'));
@@ -1,8 +1,6 @@
1
1
  import { faPlus } from '@fortawesome/free-solid-svg-icons';
2
2
  import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3
3
  import { clsx } from 'clsx';
4
- import type { MotionProps } from 'framer-motion';
5
- import { AnimatePresence, motion } from 'framer-motion';
6
4
  import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
7
5
  import { useState } from 'react';
8
6
  import { ChevronRight, Icon } from '../icon';
@@ -31,7 +29,7 @@ export type AccordionProps = {
31
29
  overrides?: {
32
30
  container?: ComponentPropsWithoutRef<'div'>;
33
31
  titleContainer?: ComponentPropsWithoutRef<'div'>;
34
- dropdownContainer?: ComponentPropsWithoutRef<'div'> & MotionProps;
32
+ dropdownContainer?: ComponentPropsWithoutRef<'div'>;
35
33
  dropdownContent?: ComponentPropsWithoutRef<'div'>;
36
34
  };
37
35
  };
@@ -107,37 +105,34 @@ export const Accordion: FC<AccordionProps> = ({
107
105
  <Icon icon={ChevronRight} size={16} className={clsx(styles.chevron, open && styles.open)} />
108
106
  )}
109
107
  </div>
110
- <AnimatePresence>
111
- {open && (
112
- <motion.div
113
- key="content"
114
- initial="collapsed"
115
- animate="open"
116
- exit="collapsed"
117
- variants={{
118
- open: { opacity: 1, height: 'auto', y: 0 },
119
- collapsed: { opacity: 0, height: 0, y: -10 },
120
- }}
121
- transition={{
122
- duration: 0.8,
123
- ease: [0.87, 0, 0.13, 1],
124
- }}
125
- {...overrides?.dropdownContainer}
126
- className={clsx(styles.dropdown, overrides?.dropdownContainer?.className)}
108
+ {/*
109
+ * Collapse uses the CSS grid-rows trick (`1fr` → `0fr`) instead of
110
+ * animating `height: auto` via JS. JS height-auto animations flash an
111
+ * intermediate layout state on open/close that causes scrollable
112
+ * ancestors to clamp `scrollTop` to 0 in the first paint frame — a
113
+ * visible scroll-snap before the animation starts.
114
+ */}
115
+ <div
116
+ aria-hidden={!open}
117
+ {...overrides?.dropdownContainer}
118
+ className={clsx(styles.dropdown, open && styles.open, overrides?.dropdownContainer?.className)}
119
+ >
120
+ {/*
121
+ * dropdownClip is the grid item. It owns `min-height: 0` and
122
+ * `overflow: hidden` so the parent's `grid-template-rows: 0fr`
123
+ * can fully collapse to 0 height. Padding/background-color
124
+ * stay on .dropdownContent (one level deeper) so they don't
125
+ * extend the grid item's box when closed.
126
+ */}
127
+ <div className={styles.dropdownClip}>
128
+ <div
129
+ {...overrides?.dropdownContent}
130
+ className={clsx(styles.dropdownContent, styles[size], overrides?.dropdownContent?.className)}
127
131
  >
128
- <div
129
- {...overrides?.dropdownContent}
130
- className={clsx(
131
- styles.dropdownContent,
132
- styles[size],
133
- overrides?.dropdownContent?.className,
134
- )}
135
- >
136
- <TextWhenString kind="paragraphXSmall">{children}</TextWhenString>
137
- </div>
138
- </motion.div>
139
- )}
140
- </AnimatePresence>
132
+ <TextWhenString kind="paragraphXSmall">{children}</TextWhenString>
133
+ </div>
134
+ </div>
135
+ </div>
141
136
  </div>
142
137
  );
143
138
  };
@@ -53,10 +53,22 @@
53
53
  }
54
54
 
55
55
  .dropdown {
56
- overflow: hidden;
56
+ display: grid;
57
+ grid-template-rows: 0fr;
58
+ opacity: 0;
59
+ transition:
60
+ grid-template-rows 800ms cubic-bezier(0.87, 0, 0.13, 1),
61
+ opacity 800ms cubic-bezier(0.87, 0, 0.13, 1);
62
+
63
+ &.open {
64
+ grid-template-rows: 1fr;
65
+ opacity: 1;
66
+ }
57
67
  }
58
68
 
59
69
  .dropdownContent {
70
+ min-height: 0;
71
+ overflow: hidden;
60
72
  display: flex;
61
73
  flex-direction: column;
62
74
  padding: 0;
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import { render, screen, waitFor } from '../../test/render';
2
+ import { render, screen, waitFor, within } from '../../test/render';
3
3
  import type { AccordionSelectOption } from './AccordionSelect';
4
4
  import { AccordionSelect } from './AccordionSelect';
5
5
 
@@ -38,7 +38,8 @@ describe('AccordionSelect', () => {
38
38
 
39
39
  it('displays the selected option in the header', () => {
40
40
  render(<AccordionSelect options={options} value="champagne" />);
41
- expect(screen.getByText('In an alleyway, drinking champagne')).toBeInTheDocument();
41
+ // Option text also appears in the collapsed-but-mounted dropdown list, so scope to the header
42
+ expect(within(screen.getByRole('button')).getByText('In an alleyway, drinking champagne')).toBeInTheDocument();
42
43
  });
43
44
 
44
45
  it('expands to show all options when header is clicked', async () => {
@@ -253,7 +254,9 @@ describe('AccordionSelect', () => {
253
254
  describe('uncontrolled selection', () => {
254
255
  it('renders with defaultValue', () => {
255
256
  render(<AccordionSelect options={options} defaultValue="champagne" />);
256
- expect(screen.getByText('In an alleyway, drinking champagne')).toBeInTheDocument();
257
+ expect(
258
+ within(screen.getByRole('button')).getByText('In an alleyway, drinking champagne'),
259
+ ).toBeInTheDocument();
257
260
  });
258
261
 
259
262
  it('renders with placeholder when no defaultValue', () => {
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
 
3
3
  import { clsx } from 'clsx';
4
- import { AnimatePresence, motion } from 'framer-motion';
5
4
  import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
6
5
  import { useEffect, useRef } from 'react';
7
6
  import { useControllableState } from '../../helpers/useControllableState';
@@ -195,60 +194,56 @@ export const AccordionSelect: FC<AccordionSelectProps> = ({
195
194
  </div>
196
195
  </div>
197
196
 
198
- <AnimatePresence>
199
- {open && (
200
- <motion.div
201
- key="content"
202
- initial="collapsed"
203
- animate="open"
204
- exit="collapsed"
205
- variants={{
206
- open: { opacity: 1, height: 'auto' },
207
- collapsed: { opacity: 0, height: 0 },
208
- }}
209
- transition={{
210
- duration: 0.8,
211
- ease: [0.87, 0, 0.13, 1],
212
- }}
213
- className={clsx(styles.dropdown, overrides?.dropdown?.className)}
214
- >
215
- <div
216
- {...overrides?.dropdownContent}
217
- className={clsx(styles.dropdownContent, overrides?.dropdownContent?.className)}
218
- >
219
- {options.map((option) => {
220
- const isOptionSelected = option.id === resolvedValue;
221
- return (
222
- <button
223
- key={option.id}
224
- type="button"
225
- disabled={option.disabled}
226
- data-selected={isOptionSelected}
227
- {...overrides?.option}
228
- className={clsx(styles.option, overrides?.option?.className)}
229
- onClick={() => {
230
- setResolvedValue(option.id);
231
- if (closeOnSelect) setOpen(false);
232
- }}
233
- >
234
- <div className={styles.optionContent}>
235
- {renderOption ? (
236
- renderOption(option, isOptionSelected)
237
- ) : (
238
- <TextWhenString kind="paragraphXSmall" weight="medium">
239
- {option.node}
240
- </TextWhenString>
241
- )}
242
- </div>
243
- {isOptionSelected && <Icon icon={Check} size={13} className={styles.check} />}
244
- </button>
245
- );
246
- })}
247
- {action}
248
- </div>
249
- </motion.div>
250
- )}
251
- </AnimatePresence>
197
+ {/*
198
+ * Collapse animation uses the CSS grid-rows trick (`1fr` → `0fr`) instead
199
+ * of animating `height: auto`. JS-driven height-auto animations (e.g.
200
+ * framer-motion) flash an intermediate layout state on open/close that
201
+ * causes scrollable ancestors to clamp `scrollTop` to 0 in the first
202
+ * paint frame — visible as an instant scroll snap before the animation
203
+ * starts. The grid-rows approach keeps the layout stable across the
204
+ * entire transition.
205
+ */}
206
+ <div
207
+ {...overrides?.dropdown}
208
+ aria-hidden={!open}
209
+ className={clsx(styles.dropdown, open && styles.open, overrides?.dropdown?.className)}
210
+ >
211
+ <div
212
+ {...overrides?.dropdownContent}
213
+ className={clsx(styles.dropdownContent, overrides?.dropdownContent?.className)}
214
+ >
215
+ {options.map((option) => {
216
+ const isOptionSelected = option.id === resolvedValue;
217
+ return (
218
+ <button
219
+ key={option.id}
220
+ type="button"
221
+ disabled={option.disabled || !open}
222
+ tabIndex={open ? undefined : -1}
223
+ data-selected={isOptionSelected}
224
+ {...overrides?.option}
225
+ className={clsx(styles.option, overrides?.option?.className)}
226
+ onClick={() => {
227
+ setResolvedValue(option.id);
228
+ if (closeOnSelect) setOpen(false);
229
+ }}
230
+ >
231
+ <div className={styles.optionContent}>
232
+ {renderOption ? (
233
+ renderOption(option, isOptionSelected)
234
+ ) : (
235
+ <TextWhenString kind="paragraphXSmall" weight="medium">
236
+ {option.node}
237
+ </TextWhenString>
238
+ )}
239
+ </div>
240
+ {isOptionSelected && <Icon icon={Check} size={13} className={styles.check} />}
241
+ </button>
242
+ );
243
+ })}
244
+ {action}
245
+ </div>
246
+ </div>
252
247
  </div>
253
248
  );
254
249
  };
@@ -34,6 +34,6 @@ $font-styles: (
34
34
  @each $style-name,
35
35
  $style-value in $font-styles {
36
36
  .fontStyle-#{$style-name} {
37
- font-style: $style-value;
37
+ font-style: $style-value !important;
38
38
  }
39
39
  }