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 +44 -0
- package/package.json +44 -44
- package/src/stories/accordion/Accordion.module.scss +46 -2
- package/src/stories/accordion/Accordion.stories.ts +13 -0
- package/src/stories/accordion/Accordion.test.tsx +7 -5
- package/src/stories/accordion/Accordion.tsx +41 -36
- package/src/stories/accordionselect/AccordionSelect.module.scss +13 -1
- package/src/stories/accordionselect/AccordionSelect.test.tsx +6 -3
- package/src/stories/accordionselect/AccordionSelect.tsx +50 -55
- package/src/stories/codeinput/CodeInput.module.scss +77 -0
- package/src/stories/codeinput/CodeInput.stories.ts +51 -0
- package/src/stories/codeinput/CodeInput.test.tsx +56 -0
- package/src/stories/codeinput/CodeInput.tsx +160 -0
- package/src/stories/codeinput/index.ts +1 -0
- package/src/stories/text/Text.module.scss +1 -1
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.
|
|
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.
|
|
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.
|
|
94
|
-
"@headlessui/react": "^2.2.
|
|
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.
|
|
98
|
-
"@tiptap/extension-link": "^3.
|
|
99
|
-
"@tiptap/extension-placeholder": "^3.
|
|
100
|
-
"@tiptap/extension-table": "^3.
|
|
101
|
-
"@tiptap/extension-table-cell": "^3.
|
|
102
|
-
"@tiptap/extension-table-header": "^3.
|
|
103
|
-
"@tiptap/extension-table-row": "^3.
|
|
104
|
-
"@tiptap/extension-task-item": "^3.
|
|
105
|
-
"@tiptap/extension-task-list": "^3.
|
|
106
|
-
"@tiptap/markdown": "^3.
|
|
107
|
-
"@tiptap/react": "^3.
|
|
108
|
-
"@tiptap/starter-kit": "^3.
|
|
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.
|
|
112
|
-
"lucide-react": "^1.
|
|
112
|
+
"framer-motion": "^12.40.0",
|
|
113
|
+
"lucide-react": "^1.16.0",
|
|
113
114
|
"pte": "^0.5.0",
|
|
114
|
-
"react-hot-toast": "^2.
|
|
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.
|
|
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.
|
|
131
|
-
"@changesets/cli": "^2.
|
|
132
|
-
"@commitlint/cli": "^
|
|
133
|
-
"@commitlint/config-conventional": "^
|
|
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.
|
|
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.
|
|
147
|
-
"autoprefixer": "^10.
|
|
147
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
148
|
+
"autoprefixer": "^10.5.0",
|
|
148
149
|
"change-case": "^4.1.2",
|
|
149
|
-
"csstype": "^3.
|
|
150
|
-
"
|
|
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.
|
|
155
|
-
"next": "^16.2.
|
|
156
|
-
"playwright": "^1.
|
|
157
|
-
"react": "^19.
|
|
158
|
-
"react-dom": "^19.
|
|
159
|
-
"sass": "^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.
|
|
164
|
-
"tsup": "^
|
|
165
|
-
"type-fest": "^3.
|
|
166
|
-
"typescript": "^5.
|
|
167
|
-
"vite": "^7.
|
|
168
|
-
"vitest": "^4.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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')).
|
|
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'
|
|
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
|
-
{
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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';
|