paris 0.22.1 → 0.23.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,49 @@
1
1
  # paris
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3efb4ff: Add `CodeInput` and a custom toggle icon for `Accordion`:
8
+
9
+ - **New `CodeInput` component.** A segmented numeric code / one-time-PIN input — `length` single-digit cells (default 6) with auto-advance, paste-to-fill, backspace-retreat, and arrow-key navigation. Supports `error` status, `disabled`, and a `loading` state that locks input and sweeps a validating glare across the segments (clipped to the input bounds).
10
+ - **`Accordion` custom toggle icon.** New `icon` prop (an `Enhancer` — an icon element or `({ size }) => ReactNode`) overrides the default plus/chevron with any icon, rotating it on open/close. Default behavior is unchanged when `icon` is omitted.
11
+
12
+ ## 0.22.2
13
+
14
+ ### Patch Changes
15
+
16
+ - 5173ecf: Fix scroll snap when collapsing `Accordion` and `AccordionSelect` inside a scrollable parent.
17
+
18
+ When the dropdown was opened, the user scrolled past it, and then closed it,
19
+ the scrollable ancestor would instantly snap to the position it would occupy
20
+ once the dropdown was fully closed — while the visual collapse animation was
21
+ still running. The cause was framer-motion's `height: 'auto' → 0` exit
22
+ animation thrashing the layout in the first paint frame, which the browser
23
+ responded to by clamping `scrollTop`.
24
+
25
+ The collapse animation now uses the CSS grid-rows trick (`grid-template-rows:
26
+ 1fr` → `0fr`) instead. Layout stays stable across the entire transition, so
27
+ `scrollTop` clamps smoothly in step with the animation. Duration and easing
28
+ match the previous behavior (800ms, `cubic-bezier(0.87, 0, 0.13, 1)`).
29
+
30
+ One small behavior change: collapsed content remains in the DOM (it was
31
+ previously unmounted by `AnimatePresence`). The container is marked
32
+ `aria-hidden` when closed and option buttons receive `tabIndex={-1}`, so
33
+ screen readers and keyboard navigation continue to skip hidden content.
34
+
35
+ - 5173ecf: Update runtime dependencies to current semver-compatible versions and patch
36
+ security advisories. Notable bumps: Tiptap 3.22 → 3.23, Framer Motion 12.24
37
+ → 12.40, Headless UI 2.2.4 → 2.2.10, Ariakit 0.4.20 → 0.4.28, react-hot-toast
38
+ 2.4 → 2.6, lucide-react 1.7 → 1.16, ts-deepmerge 6.0 → 6.2. No API changes
39
+ expected, but consumers will pick up the newer transitives on install.
40
+ - 5173ecf: `<Text fontStyle="italic">` is now reliably italic. Mirror the pattern already
41
+ used by weight classes and apply `!important` to `.fontStyle-*` rules.
42
+ Without it, the per-kind typography classes (e.g. `.paragraphSmall { font-style: normal }`,
43
+ emitted when consumers define per-style `font-style` theme variables) win
44
+ over `.fontStyle-italic` and suppress italic on `<Text>` and anything that
45
+ delegates to it — notably `<Markdown>` rendering `<em>`.
46
+
3
47
  ## 0.22.1
4
48
 
5
49
  ### 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.23.0",
6
6
  "homepage": "https://paris.slingshot.fm",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -60,6 +60,7 @@
60
60
  "./card": "./src/stories/card/index.ts",
61
61
  "./cardbutton": "./src/stories/cardbutton/index.ts",
62
62
  "./checkbox": "./src/stories/checkbox/index.ts",
63
+ "./codeinput": "./src/stories/codeinput/index.ts",
63
64
  "./combobox": "./src/stories/combobox/index.ts",
64
65
  "./dialog": "./src/stories/dialog/index.ts",
65
66
  "./drawer": "./src/stories/drawer/index.ts",
@@ -85,38 +86,38 @@
85
86
  "./utility": "./src/stories/utility/index.ts"
86
87
  },
87
88
  "dependencies": {
88
- "@ariakit/react": "^0.4.20",
89
+ "@ariakit/react": "^0.4.28",
89
90
  "@emotion/is-prop-valid": "^1.4.0",
90
91
  "@fortawesome/fontawesome-svg-core": "^6.7.2",
91
92
  "@fortawesome/free-regular-svg-icons": "^6.7.2",
92
93
  "@fortawesome/free-solid-svg-icons": "^6.7.2",
93
- "@fortawesome/react-fontawesome": "^0.2.2",
94
- "@headlessui/react": "^2.2.4",
94
+ "@fortawesome/react-fontawesome": "^0.2.6",
95
+ "@headlessui/react": "^2.2.10",
95
96
  "@radix-ui/react-checkbox": "^1.3.3",
96
97
  "@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",
98
+ "@tiptap/extension-image": "^3.23.6",
99
+ "@tiptap/extension-link": "^3.23.6",
100
+ "@tiptap/extension-placeholder": "^3.23.6",
101
+ "@tiptap/extension-table": "^3.23.6",
102
+ "@tiptap/extension-table-cell": "^3.23.6",
103
+ "@tiptap/extension-table-header": "^3.23.6",
104
+ "@tiptap/extension-table-row": "^3.23.6",
105
+ "@tiptap/extension-task-item": "^3.23.6",
106
+ "@tiptap/extension-task-list": "^3.23.6",
107
+ "@tiptap/markdown": "^3.23.6",
108
+ "@tiptap/react": "^3.23.6",
109
+ "@tiptap/starter-kit": "^3.23.6",
109
110
  "clsx": "^1.2.1",
110
111
  "font-color-contrast": "^11.1.0",
111
- "framer-motion": "^12.24.10",
112
- "lucide-react": "^1.7.0",
112
+ "framer-motion": "^12.40.0",
113
+ "lucide-react": "^1.16.0",
113
114
  "pte": "^0.5.0",
114
- "react-hot-toast": "^2.4.1",
115
+ "react-hot-toast": "^2.6.0",
115
116
  "react-markdown": "^10.1.0",
116
117
  "react-tiny-popover": "^8.1.6",
117
118
  "rehype-raw": "^7.0.0",
118
119
  "remark-gfm": "^4.0.1",
119
- "ts-deepmerge": "^6.0.3"
120
+ "ts-deepmerge": "^6.2.1"
120
121
  },
121
122
  "peerDependencies": {
122
123
  "@types/react": "^19",
@@ -127,10 +128,10 @@
127
128
  "typescript": "^5.0"
128
129
  },
129
130
  "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",
131
+ "@biomejs/biome": "^2.4.15",
132
+ "@changesets/cli": "^2.31.0",
133
+ "@commitlint/cli": "^21",
134
+ "@commitlint/config-conventional": "^21",
134
135
  "@ssh/csstypes": "^1.1.0",
135
136
  "@storybook/addon-docs": "10.3.4",
136
137
  "@storybook/addon-links": "10.3.4",
@@ -139,32 +140,31 @@
139
140
  "@testing-library/jest-dom": "^6.9.1",
140
141
  "@testing-library/react": "^16.3.2",
141
142
  "@testing-library/user-event": "^14.6.1",
142
- "@types/node": "^22.0.0",
143
- "@types/react": "^19",
144
- "@types/react-dom": "^19",
143
+ "@types/node": "^22.19.19",
144
+ "@types/react": "^19.2.15",
145
+ "@types/react-dom": "^19.2.3",
145
146
  "@vitest/browser-playwright": "4.1.2",
146
- "@vitest/coverage-v8": "^4.1.2",
147
- "autoprefixer": "^10.4.14",
147
+ "@vitest/coverage-v8": "^4.1.7",
148
+ "autoprefixer": "^10.5.0",
148
149
  "change-case": "^4.1.2",
149
- "csstype": "^3.1.2",
150
- "esbuild-sass-plugin": "^2.16.0",
151
- "jsdom": "^29.0.1",
150
+ "csstype": "^3.2.3",
151
+ "jsdom": "^29.1.1",
152
152
  "jss": "^10.10.0",
153
153
  "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",
154
+ "lefthook": "^1.13.6",
155
+ "next": "^16.2.6",
156
+ "playwright": "^1.60.0",
157
+ "react": "^19.2.6",
158
+ "react-dom": "^19.2.6",
159
+ "sass": "^1.100.0",
160
160
  "storybook": "10.3.4",
161
161
  "storybook-dark-mode": "^5.0.0",
162
162
  "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"
163
+ "ts-node": "^10.9.2",
164
+ "tsup": "^8",
165
+ "type-fest": "^3.13.1",
166
+ "typescript": "^5.9.3",
167
+ "vite": "^7.3.3",
168
+ "vitest": "^4.1.7"
169
169
  }
170
170
  }
@@ -1,3 +1,17 @@
1
+ // Shared toggle icon (used when a custom `icon` is passed): flips a
2
+ // down-pointing icon (e.g. a chevron) up when open, the standard accordion cue.
3
+ .toggleIcon {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ padding: 2px;
7
+ transition: transform var(--pte-animations-duration-gradual) var(--pte-animations-timing-easeInOutExpo);
8
+ transform: rotate(0);
9
+
10
+ &.open {
11
+ transform: rotate(-180deg);
12
+ }
13
+ }
14
+
1
15
  .default {
2
16
  color: var(--pte-new-colors-contentPrimary);
3
17
  border-bottom: 1px solid var(--pte-new-colors-borderMedium);
@@ -39,8 +53,23 @@
39
53
  }
40
54
 
41
55
  .dropdown {
42
- overflow: hidden;
56
+ display: grid;
57
+ grid-template-rows: 0fr;
58
+ opacity: 0;
43
59
  cursor: auto;
60
+ transition:
61
+ grid-template-rows 800ms cubic-bezier(0.87, 0, 0.13, 1),
62
+ opacity 800ms cubic-bezier(0.87, 0, 0.13, 1);
63
+
64
+ &.open {
65
+ grid-template-rows: 1fr;
66
+ opacity: 1;
67
+ }
68
+ }
69
+
70
+ .dropdownClip {
71
+ min-height: 0;
72
+ overflow: hidden;
44
73
  }
45
74
 
46
75
  .dropdownContent {
@@ -100,8 +129,23 @@
100
129
  }
101
130
 
102
131
  .dropdown {
103
- overflow: hidden;
132
+ display: grid;
133
+ grid-template-rows: 0fr;
134
+ opacity: 0;
104
135
  cursor: auto;
136
+ transition:
137
+ grid-template-rows 800ms cubic-bezier(0.87, 0, 0.13, 1),
138
+ opacity 800ms cubic-bezier(0.87, 0, 0.13, 1);
139
+
140
+ &.open {
141
+ grid-template-rows: 1fr;
142
+ opacity: 1;
143
+ }
144
+ }
145
+
146
+ .dropdownClip {
147
+ min-height: 0;
148
+ overflow: hidden;
105
149
  }
106
150
 
107
151
  .dropdownContent {
@@ -1,4 +1,7 @@
1
+ import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
2
+ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
1
3
  import type { Meta, StoryObj } from '@storybook/nextjs-vite';
4
+ import { createElement } from 'react';
2
5
  import { Accordion } from './Accordion';
3
6
 
4
7
  const meta: Meta<typeof Accordion> = {
@@ -33,3 +36,13 @@ export const CardLarge: Story = {
33
36
  size: 'large',
34
37
  },
35
38
  };
39
+
40
+ export const CustomIcon: Story = {
41
+ args: {
42
+ title: 'Where were we?',
43
+ children: 'In an alleyway, drinking champagne.',
44
+ kind: 'card',
45
+ icon: ({ size }) =>
46
+ createElement(FontAwesomeIcon, { icon: faChevronDown, style: { width: size, height: size } }),
47
+ },
48
+ };
@@ -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,10 +1,10 @@
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';
6
+ import { renderEnhancer } from '../../helpers/renderEnhancer';
7
+ import type { Enhancer } from '../../types/Enhancer';
8
8
  import { ChevronRight, Icon } from '../icon';
9
9
  import { TextWhenString } from '../utility';
10
10
  import styles from './Accordion.module.scss';
@@ -22,6 +22,12 @@ export type AccordionProps = {
22
22
  * @default small
23
23
  */
24
24
  size?: 'small' | 'large';
25
+ /**
26
+ * Overrides the toggle icon. Accepts any icon (an `Icon` element or a render
27
+ * function `({ size }) => ReactNode`). When set, it replaces the default
28
+ * plus/chevron and rotates on open/close like the built-in chevron.
29
+ */
30
+ icon?: Enhancer;
25
31
  /** Whether the Accordion is open. If provided, the Accordion will be a controlled component. */
26
32
  isOpen?: boolean;
27
33
  /** A handler for when the Accordion state changes. */
@@ -31,7 +37,7 @@ export type AccordionProps = {
31
37
  overrides?: {
32
38
  container?: ComponentPropsWithoutRef<'div'>;
33
39
  titleContainer?: ComponentPropsWithoutRef<'div'>;
34
- dropdownContainer?: ComponentPropsWithoutRef<'div'> & MotionProps;
40
+ dropdownContainer?: ComponentPropsWithoutRef<'div'>;
35
41
  dropdownContent?: ComponentPropsWithoutRef<'div'>;
36
42
  };
37
43
  };
@@ -52,6 +58,7 @@ export const Accordion: FC<AccordionProps> = ({
52
58
  title,
53
59
  kind = 'default',
54
60
  size = 'small',
61
+ icon,
55
62
  isOpen,
56
63
  onOpenChange,
57
64
  children,
@@ -98,46 +105,44 @@ export const Accordion: FC<AccordionProps> = ({
98
105
  <TextWhenString kind="paragraphSmall" weight="medium">
99
106
  {title}
100
107
  </TextWhenString>
101
- {kind === 'default' && (
108
+ {icon ? (
109
+ <span className={clsx(styles.toggleIcon, open && styles.open)}>{renderEnhancer(icon, 16)}</span>
110
+ ) : kind === 'default' ? (
102
111
  <div className={styles.plusIcon}>
103
112
  <FontAwesomeIcon icon={faPlus} className={clsx(open && styles.open)} />
104
113
  </div>
105
- )}
106
- {kind === 'card' && (
114
+ ) : (
107
115
  <Icon icon={ChevronRight} size={16} className={clsx(styles.chevron, open && styles.open)} />
108
116
  )}
109
117
  </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)}
118
+ {/*
119
+ * Collapse uses the CSS grid-rows trick (`1fr` → `0fr`) instead of
120
+ * animating `height: auto` via JS. JS height-auto animations flash an
121
+ * intermediate layout state on open/close that causes scrollable
122
+ * ancestors to clamp `scrollTop` to 0 in the first paint frame — a
123
+ * visible scroll-snap before the animation starts.
124
+ */}
125
+ <div
126
+ aria-hidden={!open}
127
+ {...overrides?.dropdownContainer}
128
+ className={clsx(styles.dropdown, open && styles.open, overrides?.dropdownContainer?.className)}
129
+ >
130
+ {/*
131
+ * dropdownClip is the grid item. It owns `min-height: 0` and
132
+ * `overflow: hidden` so the parent's `grid-template-rows: 0fr`
133
+ * can fully collapse to 0 height. Padding/background-color
134
+ * stay on .dropdownContent (one level deeper) so they don't
135
+ * extend the grid item's box when closed.
136
+ */}
137
+ <div className={styles.dropdownClip}>
138
+ <div
139
+ {...overrides?.dropdownContent}
140
+ className={clsx(styles.dropdownContent, styles[size], overrides?.dropdownContent?.className)}
127
141
  >
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>
142
+ <TextWhenString kind="paragraphXSmall">{children}</TextWhenString>
143
+ </div>
144
+ </div>
145
+ </div>
141
146
  </div>
142
147
  );
143
148
  };
@@ -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
  };
@@ -0,0 +1,77 @@
1
+ .container {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 4px;
5
+ padding: 8px 0;
6
+ }
7
+
8
+ // Validating state: lock interaction and sweep a glare that enters from the right (last field).
9
+ .loading {
10
+ position: relative;
11
+ overflow: hidden;
12
+ pointer-events: none;
13
+
14
+ // The glare — a soft highlight band that enters from the right and sweeps across, looping.
15
+ &::after {
16
+ content: '';
17
+ position: absolute;
18
+ inset: 0;
19
+ pointer-events: none;
20
+ border-radius: 4px;
21
+ background: linear-gradient(
22
+ 100deg,
23
+ transparent 30%,
24
+ rgb(255 255 255 / 70%) 50%,
25
+ transparent 70%
26
+ );
27
+ transform: translateX(130%);
28
+ animation: codeGlare 1.3s ease-in-out 0.45s infinite;
29
+ }
30
+ }
31
+
32
+ @keyframes codeGlare {
33
+ to {
34
+ transform: translateX(-130%);
35
+ }
36
+ }
37
+
38
+ .segment {
39
+ box-sizing: border-box;
40
+ width: 30px;
41
+ height: 34px;
42
+ padding: 6.5px 2px;
43
+ border: 1px solid transparent;
44
+ border-radius: 4px;
45
+ background-color: var(--pte-new-colors-inputFill);
46
+ color: var(--pte-new-colors-contentPrimary);
47
+ text-align: center;
48
+ outline: none;
49
+ caret-color: var(--pte-new-colors-contentPrimary);
50
+
51
+ font-size: var(--pte-typography-styles-paragraphSmall-fontSize);
52
+ font-style: var(--pte-typography-styles-paragraphSmall-fontStyle);
53
+ font-weight: var(--pte-typography-styles-paragraphSmall-fontWeight);
54
+ letter-spacing: var(--pte-typography-styles-paragraphSmall-letterSpacing);
55
+ line-height: var(--pte-typography-styles-paragraphSmall-lineHeight);
56
+
57
+ transition: var(--pte-animations-interaction);
58
+
59
+ &:focus {
60
+ background-color: var(--pte-new-colors-inputFillFocus);
61
+ border-color: var(--pte-new-colors-inputBorderFocus);
62
+ }
63
+
64
+ &:disabled {
65
+ background-color: var(--pte-new-colors-inputFillDisabled);
66
+ color: var(--pte-new-colors-contentDisabled);
67
+ cursor: default;
68
+ pointer-events: none;
69
+ }
70
+
71
+ &.error {
72
+ background-color: var(--pte-new-colors-inputFillNegative);
73
+ border-color: var(--pte-new-colors-inputBorderNegative);
74
+ color: var(--pte-new-colors-contentNegative);
75
+ caret-color: var(--pte-new-colors-contentNegative);
76
+ }
77
+ }
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite';
2
+ import { createElement, useState } from 'react';
3
+ import { CodeInput } from './CodeInput';
4
+
5
+ const meta: Meta<typeof CodeInput> = {
6
+ title: 'Inputs/CodeInput',
7
+ component: CodeInput,
8
+ tags: ['autodocs'],
9
+ };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof CodeInput>;
13
+
14
+ export const Default: Story = {
15
+ render: function Render(args) {
16
+ const [value, setValue] = useState('');
17
+ return createElement(CodeInput, { ...args, value, onChange: setValue });
18
+ },
19
+ };
20
+
21
+ export const Error: Story = {
22
+ args: { status: 'error' },
23
+ render: function Render(args) {
24
+ const [value, setValue] = useState('123');
25
+ return createElement(CodeInput, { ...args, value, onChange: setValue });
26
+ },
27
+ };
28
+
29
+ export const Disabled: Story = {
30
+ args: { disabled: true },
31
+ render: function Render(args) {
32
+ const [value, setValue] = useState('12');
33
+ return createElement(CodeInput, { ...args, value, onChange: setValue });
34
+ },
35
+ };
36
+
37
+ export const Loading: Story = {
38
+ args: { loading: true },
39
+ render: function Render(args) {
40
+ const [value, setValue] = useState('123456');
41
+ return createElement(CodeInput, { ...args, value, onChange: setValue });
42
+ },
43
+ };
44
+
45
+ export const FourDigits: Story = {
46
+ args: { length: 4 },
47
+ render: function Render(args) {
48
+ const [value, setValue] = useState('');
49
+ return createElement(CodeInput, { ...args, value, onChange: setValue });
50
+ },
51
+ };
@@ -0,0 +1,56 @@
1
+ import { useState } from 'react';
2
+ import { render, screen } from '../../test/render';
3
+ import { CodeInput } from './CodeInput';
4
+
5
+ function Controlled({ length = 6, onComplete }: { length?: number; onComplete?: (value: string) => void }) {
6
+ const [value, setValue] = useState('');
7
+ return <CodeInput value={value} onChange={setValue} onComplete={onComplete} length={length} aria-label="code" />;
8
+ }
9
+
10
+ describe('CodeInput', () => {
11
+ describe('rendering', () => {
12
+ it('renders `length` segments (default 6)', () => {
13
+ render(<Controlled />);
14
+ expect(screen.getAllByRole('textbox')).toHaveLength(6);
15
+ });
16
+
17
+ it('respects a custom length', () => {
18
+ render(<Controlled length={4} />);
19
+ expect(screen.getAllByRole('textbox')).toHaveLength(4);
20
+ });
21
+ });
22
+
23
+ describe('entry', () => {
24
+ it('types digits across segments and ignores non-numerics', async () => {
25
+ const { user } = render(<Controlled />);
26
+ const segments = screen.getAllByRole('textbox');
27
+ await user.click(segments[0]);
28
+ await user.keyboard('12a3');
29
+ expect(segments[0]).toHaveValue('1');
30
+ expect(segments[1]).toHaveValue('2');
31
+ expect(segments[2]).toHaveValue('3');
32
+ });
33
+
34
+ it('pastes to fill all segments and fires onComplete once', async () => {
35
+ const onComplete = vi.fn();
36
+ const { user } = render(<Controlled onComplete={onComplete} />);
37
+ const segments = screen.getAllByRole('textbox');
38
+ await user.click(segments[0]);
39
+ await user.paste('123456');
40
+ expect(segments[0]).toHaveValue('1');
41
+ expect(segments[5]).toHaveValue('6');
42
+ expect(onComplete).toHaveBeenCalledTimes(1);
43
+ expect(onComplete).toHaveBeenCalledWith('123456');
44
+ });
45
+
46
+ it('backspace on an empty segment clears and retreats to the previous', async () => {
47
+ const { user } = render(<Controlled />);
48
+ const segments = screen.getAllByRole('textbox');
49
+ await user.click(segments[0]);
50
+ await user.keyboard('12');
51
+ await user.keyboard('{Backspace}');
52
+ expect(segments[1]).toHaveValue('');
53
+ expect(segments[0]).toHaveValue('1');
54
+ });
55
+ });
56
+ });
@@ -0,0 +1,160 @@
1
+ 'use client';
2
+
3
+ import { clsx } from 'clsx';
4
+ import type { ChangeEvent, ClipboardEvent, FC, KeyboardEvent } from 'react';
5
+ import { useCallback, useEffect, useRef } from 'react';
6
+ import styles from './CodeInput.module.scss';
7
+
8
+ export type CodeInputProps = {
9
+ /** The current code value. Controlled — pass the digits entered so far. */
10
+ value: string;
11
+ /** Called with the new value whenever a segment changes. */
12
+ onChange: (value: string) => void;
13
+ /** Called once the final segment is filled (i.e. `value.length === length`). */
14
+ onComplete?: (value: string) => void;
15
+ /**
16
+ * Number of digit segments.
17
+ * @default 6
18
+ */
19
+ length?: number;
20
+ /**
21
+ * Visual status of the segments.
22
+ * @default 'default'
23
+ */
24
+ status?: 'default' | 'error';
25
+ /** Disables all segments. */
26
+ disabled?: boolean;
27
+ /**
28
+ * Locks the input and plays a validating animation (a right-to-left fill, then a glare that
29
+ * sweeps across the segments). Use while a submitted code is being verified.
30
+ */
31
+ loading?: boolean;
32
+ /** Focus the first segment on mount. */
33
+ autoFocus?: boolean;
34
+ /**
35
+ * Accessible label for the group of segments.
36
+ * @default 'Verification code'
37
+ */
38
+ 'aria-label'?: string;
39
+ };
40
+
41
+ /**
42
+ * A segmented numeric code input — `length` single-digit cells with auto-advance, paste-to-fill,
43
+ * backspace-retreat, and arrow-key navigation. Intended for one-time PIN / SMS verification codes.
44
+ *
45
+ * <hr />
46
+ *
47
+ * To use this component, import it as follows:
48
+ *
49
+ * ```js
50
+ * import { CodeInput } from 'paris/codeinput';
51
+ * ```
52
+ * @constructor
53
+ */
54
+ export const CodeInput: FC<CodeInputProps> = ({
55
+ value,
56
+ onChange,
57
+ onComplete,
58
+ length = 6,
59
+ status = 'default',
60
+ disabled = false,
61
+ loading = false,
62
+ autoFocus = false,
63
+ 'aria-label': ariaLabel = 'Verification code',
64
+ }) => {
65
+ const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
66
+ const digits = value.split('').slice(0, length);
67
+
68
+ const focusAt = useCallback(
69
+ (index: number) => {
70
+ const clamped = Math.max(0, Math.min(index, length - 1));
71
+ const input = inputsRef.current[clamped];
72
+ input?.focus();
73
+ input?.select();
74
+ },
75
+ [length],
76
+ );
77
+
78
+ useEffect(() => {
79
+ if (autoFocus) focusAt(0);
80
+ }, [autoFocus, focusAt]);
81
+
82
+ const emit = useCallback(
83
+ (next: string) => {
84
+ const sliced = next.slice(0, length);
85
+ onChange(sliced);
86
+ if (sliced.length === length) onComplete?.(sliced);
87
+ },
88
+ [length, onChange, onComplete],
89
+ );
90
+
91
+ const handleChange = (index: number) => (event: ChangeEvent<HTMLInputElement>) => {
92
+ const typed = event.target.value.replace(/\D/g, '');
93
+ if (!typed) return;
94
+ const chars = value.split('');
95
+ let cursor = index;
96
+ for (const char of typed) {
97
+ if (cursor >= length) break;
98
+ chars[cursor] = char;
99
+ cursor += 1;
100
+ }
101
+ emit(chars.join(''));
102
+ focusAt(cursor);
103
+ };
104
+
105
+ const handleKeyDown = (index: number) => (event: KeyboardEvent<HTMLInputElement>) => {
106
+ const chars = value.split('');
107
+ if (event.key === 'Backspace') {
108
+ event.preventDefault();
109
+ if (chars[index]) {
110
+ chars[index] = '';
111
+ emit(chars.join(''));
112
+ } else if (index > 0) {
113
+ chars[index - 1] = '';
114
+ emit(chars.join(''));
115
+ focusAt(index - 1);
116
+ }
117
+ } else if (event.key === 'ArrowLeft') {
118
+ event.preventDefault();
119
+ focusAt(index - 1);
120
+ } else if (event.key === 'ArrowRight') {
121
+ event.preventDefault();
122
+ focusAt(index + 1);
123
+ }
124
+ };
125
+
126
+ const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
127
+ event.preventDefault();
128
+ const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length);
129
+ if (!pasted) return;
130
+ emit(pasted);
131
+ focusAt(pasted.length);
132
+ };
133
+
134
+ return (
135
+ <div className={clsx(styles.container, loading && styles.loading)} role="group" aria-label={ariaLabel}>
136
+ {Array.from({ length }).map((_, index) => (
137
+ <input
138
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length positional code segments are stable
139
+ key={index}
140
+ ref={(element) => {
141
+ inputsRef.current[index] = element;
142
+ }}
143
+ className={clsx(styles.segment, status === 'error' && styles.error)}
144
+ type="text"
145
+ inputMode="numeric"
146
+ autoComplete={index === 0 ? 'one-time-code' : 'off'}
147
+ maxLength={1}
148
+ disabled={disabled}
149
+ readOnly={loading}
150
+ value={digits[index] ?? ''}
151
+ aria-label={`Digit ${index + 1}`}
152
+ onChange={handleChange(index)}
153
+ onKeyDown={handleKeyDown(index)}
154
+ onPaste={handlePaste}
155
+ onFocus={(event) => event.target.select()}
156
+ />
157
+ ))}
158
+ </div>
159
+ );
160
+ };
@@ -0,0 +1 @@
1
+ export * from './CodeInput';
@@ -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
  }