paris 0.22.2 → 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,14 @@
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
+
3
12
  ## 0.22.2
4
13
 
5
14
  ### 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.2",
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",
@@ -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);
@@ -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
+ };
@@ -3,6 +3,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3
3
  import { clsx } from 'clsx';
4
4
  import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react';
5
5
  import { useState } from 'react';
6
+ import { renderEnhancer } from '../../helpers/renderEnhancer';
7
+ import type { Enhancer } from '../../types/Enhancer';
6
8
  import { ChevronRight, Icon } from '../icon';
7
9
  import { TextWhenString } from '../utility';
8
10
  import styles from './Accordion.module.scss';
@@ -20,6 +22,12 @@ export type AccordionProps = {
20
22
  * @default small
21
23
  */
22
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;
23
31
  /** Whether the Accordion is open. If provided, the Accordion will be a controlled component. */
24
32
  isOpen?: boolean;
25
33
  /** A handler for when the Accordion state changes. */
@@ -50,6 +58,7 @@ export const Accordion: FC<AccordionProps> = ({
50
58
  title,
51
59
  kind = 'default',
52
60
  size = 'small',
61
+ icon,
53
62
  isOpen,
54
63
  onOpenChange,
55
64
  children,
@@ -96,12 +105,13 @@ export const Accordion: FC<AccordionProps> = ({
96
105
  <TextWhenString kind="paragraphSmall" weight="medium">
97
106
  {title}
98
107
  </TextWhenString>
99
- {kind === 'default' && (
108
+ {icon ? (
109
+ <span className={clsx(styles.toggleIcon, open && styles.open)}>{renderEnhancer(icon, 16)}</span>
110
+ ) : kind === 'default' ? (
100
111
  <div className={styles.plusIcon}>
101
112
  <FontAwesomeIcon icon={faPlus} className={clsx(open && styles.open)} />
102
113
  </div>
103
- )}
104
- {kind === 'card' && (
114
+ ) : (
105
115
  <Icon icon={ChevronRight} size={16} className={clsx(styles.chevron, open && styles.open)} />
106
116
  )}
107
117
  </div>
@@ -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';