@charcoal-ui/react 6.0.0-rc.5 → 6.0.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  .charcoal-carousel {
2
+ --charcoal-carousel-gap: 0px;
2
3
  position: relative;
3
4
  display: block;
4
5
  }
@@ -19,12 +20,13 @@
19
20
  box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
20
21
  }
21
22
 
22
- /* スライド間隔は所有しない(sandbox 同様、利用者が children 側で注入する)。
23
- このコンポーネントの責務はスクロールと indicator / arrow によるページ送りのみ。 */
23
+ /* スライド間隔は gap prop(--charcoal-carousel-gap)で注入する。
24
+ スライド寸法は sandbox 同様、利用者が children 側で持つ。 */
24
25
 
25
26
  .charcoal-carousel__scroller {
26
27
  position: relative;
27
28
  display: flex;
29
+ gap: var(--charcoal-carousel-gap);
28
30
  overflow-x: auto;
29
31
  overflow-y: hidden;
30
32
  overscroll-behavior-x: contain;
@@ -130,9 +132,11 @@
130
132
  display: none;
131
133
  }
132
134
 
133
- /* hover で表示。キーボード操作でも到達できるよう focus-within でも表示する。 */
135
+ /* hover で表示。キーボード操作でも到達できるよう、キーボード由来のフォーカスが
136
+ カルーセル内にある間も表示する(:focus-within だとクリックで残ったフォーカスでも
137
+ 表示され続けるため、react-aria が付与する data 属性で判定する)。 */
134
138
 
135
- .charcoal-carousel:hover .charcoal-carousel__navigation, .charcoal-carousel:focus-within .charcoal-carousel__navigation {
139
+ .charcoal-carousel:hover .charcoal-carousel__navigation, .charcoal-carousel[data-focus-visible-within] .charcoal-carousel__navigation {
136
140
  opacity: 1;
137
141
  }
138
142
 
@@ -140,16 +144,36 @@
140
144
  pointer-events: auto;
141
145
  }
142
146
 
147
+ /* ヒットエリアをグリッド端列(= mask のフェード帯と同じ 72px)全体へ広げる。
148
+ 擬似要素上のクリックは元の button に届くため、帯のどこを押してもページ送りになる。
149
+ position: absolute の基準は positioned な .charcoal-carousel__navigation(inset: 0)。 */
150
+
151
+ .charcoal-carousel__navigation__item::after {
152
+ content: '';
153
+ position: absolute;
154
+ top: 0;
155
+ bottom: 0;
156
+ width: 72px;
157
+ }
158
+
143
159
  .charcoal-carousel__navigation__item[data-direction='prev'] {
144
160
  grid-column: 1;
145
161
  justify-self: center;
146
162
  }
147
163
 
164
+ .charcoal-carousel__navigation__item[data-direction='prev']::after {
165
+ left: 0;
166
+ }
167
+
148
168
  .charcoal-carousel__navigation__item[data-direction='next'] {
149
169
  grid-column: 3;
150
170
  justify-self: center;
151
171
  }
152
172
 
173
+ .charcoal-carousel__navigation__item[data-direction='next']::after {
174
+ right: 0;
175
+ }
176
+
153
177
  /* スクロール端では非表示。IconButton の disabled スタイル(opacity:0.32)より
154
178
  高い詳細度で打ち消すため、ルートを前置する。 */
155
179
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * ナビゲーションボタンのヒットエリア検証(実ブラウザ)
3
+ *
4
+ * hasGradient の 72px フェード帯はページ送り操作の領域に見えるが、
5
+ * クリック可能域が中央の 32px 円形ボタンだけだと、帯の残り部分の
6
+ * クリックが背後のスライド(リンク等)に素通りしてしまう。
7
+ * 帯全体がナビゲーションボタンのヒットエリアであることを
8
+ * document.elementFromPoint のヒットテストで検証する。
9
+ * (pointer-events / 擬似要素のヒットテストは jsdom では再現できない)
10
+ */
11
+ import { userEvent } from '@vitest/browser/context'
12
+ import { render } from '@testing-library/react'
13
+ import Carousel from '.'
14
+
15
+ const EDGE_ZONE_WIDTH = 72
16
+
17
+ const slides = Array.from({ length: 10 }, (_, i) => (
18
+ <div key={`slide-${i}`} style={{ width: 200, height: 160 }}>
19
+ Slide {i}
20
+ </div>
21
+ ))
22
+
23
+ function renderCarousel() {
24
+ const { container } = render(
25
+ <div style={{ width: 400 }}>
26
+ <Carousel size="M" hasGradient>
27
+ {slides}
28
+ </Carousel>
29
+ </div>,
30
+ )
31
+ const viewport = container.querySelector(
32
+ '.charcoal-carousel__viewport',
33
+ ) as HTMLElement
34
+ return { container, viewport, rect: viewport.getBoundingClientRect() }
35
+ }
36
+
37
+ function hitNavigationItem(x: number, y: number) {
38
+ return document
39
+ .elementFromPoint(x, y)
40
+ ?.closest('.charcoal-carousel__navigation__item')
41
+ }
42
+
43
+ describe('navigation button hit area', () => {
44
+ it('右端 72px 帯のクリックはスライドではなく next ボタンに届く', () => {
45
+ const { rect } = renderCarousel()
46
+ const centerY = rect.top + rect.height / 2
47
+
48
+ // ボタン円(32px、帯の中央)の外側かつ帯の内側の点
49
+ const nearEdge = hitNavigationItem(rect.right - 8, centerY)
50
+ expect(nearEdge?.getAttribute('data-direction')).toBe('next')
51
+
52
+ // ボタンの上下方向の外側(帯の上端付近)
53
+ const aboveButton = hitNavigationItem(
54
+ rect.right - EDGE_ZONE_WIDTH / 2,
55
+ rect.top + 8,
56
+ )
57
+ expect(aboveButton?.getAttribute('data-direction')).toBe('next')
58
+ })
59
+
60
+ it('スクロール不能な側(初期位置の prev)の帯はスライドへ素通りする', () => {
61
+ const { rect } = renderCarousel()
62
+ const centerY = rect.top + rect.height / 2
63
+
64
+ const hit = document.elementFromPoint(rect.left + 8, centerY)
65
+ expect(hit?.closest('.charcoal-carousel__navigation__item')).toBeNull()
66
+ expect(hit?.closest('.charcoal-carousel__scroller')).not.toBeNull()
67
+ })
68
+
69
+ it('帯のボタン外の点をクリックすると next へページ送りされる', async () => {
70
+ const { container, viewport, rect } = renderCarousel()
71
+ const scroller = container.querySelector(
72
+ '.charcoal-carousel__scroller',
73
+ ) as HTMLElement
74
+ expect(scroller.scrollLeft).toBe(0)
75
+
76
+ // trusted click とその後の scroll イベントは React の act() 管理外で
77
+ // 発火するため、この区間だけ act 警告を無効化する。
78
+ const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
79
+ g.IS_REACT_ACT_ENVIRONMENT = false
80
+ try {
81
+ // viewport 左上基準・帯の内側かつボタン円の外側の座標を実クリックする
82
+ await userEvent.click(viewport, {
83
+ position: { x: rect.width - 8, y: 8 },
84
+ })
85
+
86
+ await vi.waitFor(() => {
87
+ expect(scroller.scrollLeft).toBeGreaterThan(0)
88
+ })
89
+ } finally {
90
+ g.IS_REACT_ACT_ENVIRONMENT = true
91
+ }
92
+ })
93
+
94
+ it('帯の外側(中央領域)のクリックはスライドに届く', () => {
95
+ const { rect } = renderCarousel()
96
+ const centerY = rect.top + rect.height / 2
97
+
98
+ const hit = document.elementFromPoint(rect.left + rect.width / 2, centerY)
99
+ expect(hit?.closest('.charcoal-carousel__navigation__item')).toBeNull()
100
+ expect(hit?.closest('.charcoal-carousel__scroller')).not.toBeNull()
101
+ })
102
+ })
@@ -1,4 +1,6 @@
1
1
  .charcoal-carousel {
2
+ --charcoal-carousel-gap: 0px;
3
+
2
4
  position: relative;
3
5
  display: block;
4
6
 
@@ -18,11 +20,12 @@
18
20
  }
19
21
  }
20
22
 
21
- /* スライド間隔は所有しない(sandbox 同様、利用者が children 側で注入する)。
22
- このコンポーネントの責務はスクロールと indicator / arrow によるページ送りのみ。 */
23
+ /* スライド間隔は gap prop(--charcoal-carousel-gap)で注入する。
24
+ スライド寸法は sandbox 同様、利用者が children 側で持つ。 */
23
25
  .charcoal-carousel__scroller {
24
26
  position: relative;
25
27
  display: flex;
28
+ gap: var(--charcoal-carousel-gap);
26
29
  overflow-x: auto;
27
30
  overflow-y: hidden;
28
31
  overscroll-behavior-x: contain;
@@ -134,23 +137,44 @@
134
137
  }
135
138
  }
136
139
 
137
- /* hover で表示。キーボード操作でも到達できるよう focus-within でも表示する。 */
140
+ /* hover で表示。キーボード操作でも到達できるよう、キーボード由来のフォーカスが
141
+ カルーセル内にある間も表示する(:focus-within だとクリックで残ったフォーカスでも
142
+ 表示され続けるため、react-aria が付与する data 属性で判定する)。 */
138
143
  .charcoal-carousel:hover .charcoal-carousel__navigation,
139
- .charcoal-carousel:focus-within .charcoal-carousel__navigation {
144
+ .charcoal-carousel[data-focus-visible-within] .charcoal-carousel__navigation {
140
145
  opacity: 1;
141
146
  }
142
147
 
143
148
  .charcoal-carousel__navigation__item {
144
149
  pointer-events: auto;
145
150
 
151
+ /* ヒットエリアをグリッド端列(= mask のフェード帯と同じ 72px)全体へ広げる。
152
+ 擬似要素上のクリックは元の button に届くため、帯のどこを押してもページ送りになる。
153
+ position: absolute の基準は positioned な .charcoal-carousel__navigation(inset: 0)。 */
154
+ &::after {
155
+ content: '';
156
+ position: absolute;
157
+ top: 0;
158
+ bottom: 0;
159
+ width: 72px;
160
+ }
161
+
146
162
  &[data-direction='prev'] {
147
163
  grid-column: 1;
148
164
  justify-self: center;
165
+
166
+ &::after {
167
+ left: 0;
168
+ }
149
169
  }
150
170
 
151
171
  &[data-direction='next'] {
152
172
  grid-column: 3;
153
173
  justify-self: center;
174
+
175
+ &::after {
176
+ right: 0;
177
+ }
154
178
  }
155
179
  }
156
180
 
@@ -16,25 +16,18 @@ const makeSampleImages = (style: CSSProperties) =>
16
16
  />
17
17
  ))
18
18
 
19
- // M 用: スライド間隔はコンポーネントが所有しないので、利用者側の margin で注入する。
20
- // width: 100% margin の併用はラッパーとの循環サイズ解決で間隔が潰れるため、明示サイズにする。
21
- const spacedImages = makeSampleImages({
22
- width: 280,
23
- height: 210,
24
- marginInlineEnd: 24,
25
- })
19
+ // M 用: スライド間隔は gap prop で注入する(既定 args の gap: 24)。
20
+ const spacedImages = makeSampleImages({ width: 280, height: 210 })
26
21
  // S 用: 1 枚全幅ページングのため間隔なし
27
22
  const fullWidthImages = makeSampleImages({ width: '100%', height: '100%' })
28
23
 
29
24
  // 横幅が確定したスロット(defaultScroll の初期位置計算と 0.75 ページ送りを確認するため)。
30
- // スライド間隔はコンポーネントが所有しないので、利用者側の margin で注入する。
31
25
  const numberedSlides = Array.from({ length: 20 }, (_, i) => (
32
26
  <div
33
27
  key={`num-${i + 1}`}
34
28
  style={{
35
29
  width: 200,
36
30
  height: 120,
37
- marginInlineEnd: 24,
38
31
  display: 'flex',
39
32
  alignItems: 'center',
40
33
  justifyContent: 'center',
@@ -60,6 +53,7 @@ export default {
60
53
  hasGradient: false,
61
54
  fullWidth: false,
62
55
  scrollStep: 0.75,
56
+ gap: 24,
63
57
  },
64
58
  argTypes: {
65
59
  children: { control: false },
@@ -80,6 +74,10 @@ export default {
80
74
  // Controls では数値(比率)のみ操作可能。関数形式は ScrollStepFunction story を参照。
81
75
  control: { type: 'range', min: 0.1, max: 1.5, step: 0.05 },
82
76
  },
77
+ gap: {
78
+ // number は px。'1rem' などの CSS 値文字列も渡せる。
79
+ control: { type: 'number' },
80
+ },
83
81
  },
84
82
  } satisfies Meta<typeof Carousel>
85
83
 
@@ -88,7 +86,7 @@ export const SizeM: StoryObj<typeof Carousel> = {
88
86
  }
89
87
 
90
88
  export const SizeS: StoryObj<typeof Carousel> = {
91
- args: { size: 'S', children: fullWidthImages },
89
+ args: { size: 'S', children: fullWidthImages, gap: 0 },
92
90
  }
93
91
 
94
92
  export const WithGradient: StoryObj<typeof Carousel> = {
@@ -100,7 +98,12 @@ export const FullWidth: StoryObj<typeof Carousel> = {
100
98
  }
101
99
 
102
100
  export const NavigationButtonsOnSizeS: StoryObj<typeof Carousel> = {
103
- args: { size: 'S', navigationButtons: true, children: fullWidthImages },
101
+ args: {
102
+ size: 'S',
103
+ navigationButtons: true,
104
+ children: fullWidthImages,
105
+ gap: 0,
106
+ },
104
107
  }
105
108
 
106
109
  export const IndicatorOnSizeM: StoryObj<typeof Carousel> = {
@@ -140,7 +143,6 @@ const darkTiles = Array.from({ length: 10 }, (_, i) => (
140
143
  style={{
141
144
  width: 220,
142
145
  height: 140,
143
- marginInlineEnd: 24,
144
146
  display: 'flex',
145
147
  alignItems: 'center',
146
148
  justifyContent: 'center',
@@ -92,6 +92,30 @@ describe('Carousel', () => {
92
92
  })
93
93
  })
94
94
 
95
+ describe('gap', () => {
96
+ it('sets --charcoal-carousel-gap in px when gap is a number', () => {
97
+ const { container } = render(<Carousel gap={24}>{slides}</Carousel>)
98
+ const root = container.querySelector('.charcoal-carousel') as HTMLElement
99
+ expect(root.style.getPropertyValue('--charcoal-carousel-gap')).toBe(
100
+ '24px',
101
+ )
102
+ })
103
+
104
+ it('passes string gap values through as-is', () => {
105
+ const { container } = render(<Carousel gap="1rem">{slides}</Carousel>)
106
+ const root = container.querySelector('.charcoal-carousel') as HTMLElement
107
+ expect(root.style.getPropertyValue('--charcoal-carousel-gap')).toBe(
108
+ '1rem',
109
+ )
110
+ })
111
+
112
+ it('does not set the custom property when gap is not specified', () => {
113
+ const { container } = render(<Carousel>{slides}</Carousel>)
114
+ const root = container.querySelector('.charcoal-carousel') as HTMLElement
115
+ expect(root.style.getPropertyValue('--charcoal-carousel-gap')).toBe('')
116
+ })
117
+ })
118
+
95
119
  describe('Size M (default)', () => {
96
120
  it('shows navigation buttons', () => {
97
121
  render(<Carousel size="M">{slides}</Carousel>)
@@ -634,6 +658,58 @@ describe('Carousel', () => {
634
658
  })
635
659
  })
636
660
 
661
+ describe('navigation button visibility (focus modality)', () => {
662
+ // jsdom はジオメトリが全て 0 で Next ボタンが disabled(=フォーカス不可)の
663
+ // ままになるため、スクロール余地をモックして有効化してからフォーカスする。
664
+ function renderWithFocusableNext() {
665
+ render(<Carousel size="M">{slides}</Carousel>)
666
+ const scroller = getScroller()
667
+ mockScrollerGeometry(scroller, { scrollLeft: 0 })
668
+ scroller.scrollBy = vi.fn()
669
+ act(() => {
670
+ fireEvent.scroll(scroller)
671
+ })
672
+ const next = screen.getByRole('button', { name: 'Next' })
673
+ expect(next).not.toBeDisabled()
674
+ return next
675
+ }
676
+
677
+ // react-aria は document 上の keydown / pointerdown でフォーカスの由来
678
+ // (keyboard / pointer)を判定するため、focus() の前にイベントを発火して
679
+ // モダリティを切り替える。
680
+ it('does not mark focus-visible when a button is focused by pointer', () => {
681
+ const next = renderWithFocusableNext()
682
+ fireEvent.pointerDown(next)
683
+ fireEvent.mouseDown(next)
684
+ act(() => next.focus())
685
+ fireEvent.click(next)
686
+ expect(document.activeElement).toBe(next)
687
+ expect(screen.getByRole('region')).not.toHaveAttribute(
688
+ 'data-focus-visible-within',
689
+ )
690
+ })
691
+
692
+ it('marks focus-visible when a button is focused by keyboard', () => {
693
+ const next = renderWithFocusableNext()
694
+ fireEvent.keyDown(document.body, { key: 'Tab' })
695
+ act(() => next.focus())
696
+ expect(screen.getByRole('region')).toHaveAttribute(
697
+ 'data-focus-visible-within',
698
+ 'true',
699
+ )
700
+ })
701
+
702
+ it('clears focus-visible when focus leaves the carousel', () => {
703
+ const next = renderWithFocusableNext()
704
+ fireEvent.keyDown(document.body, { key: 'Tab' })
705
+ act(() => next.focus())
706
+ act(() => next.blur())
707
+ expect(screen.getByRole('region')).not.toHaveAttribute(
708
+ 'data-focus-visible-within',
709
+ )
710
+ })
711
+ })
712
+
637
713
  describe('SSR (server rendering)', () => {
638
714
  // jsdom では window が定義されているため renderToString 時に
639
715
  // 「useLayoutEffect does nothing on the server」警告が出る(実 SSR=window 無しでは
@@ -7,9 +7,11 @@ import {
7
7
  memo,
8
8
  useCallback,
9
9
  useImperativeHandle,
10
+ useMemo,
10
11
  useRef,
11
12
  useState,
12
13
  useSyncExternalStore,
14
+ type CSSProperties,
13
15
  type ReactNode,
14
16
  } from 'react'
15
17
  import { mergeProps, useFocusRing, useKeyboard } from 'react-aria'
@@ -67,6 +69,8 @@ export type CarouselProps = Readonly<{
67
69
  onResize?: (width: number) => void
68
70
  onScrollStateChange?: (canScroll: boolean) => void
69
71
  defaultScroll?: { align?: ScrollAlign; offset?: number }
72
+ // スライド間隔。number は px、string は CSS 値をそのまま使う。未指定は間隔なし。
73
+ gap?: number | string
70
74
  // 1 直接子要素 = 1 スライド(react-sandbox 互換)。
71
75
  children: ReactNode
72
76
  }>
@@ -147,6 +151,7 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
147
151
  onResize,
148
152
  onScrollStateChange,
149
153
  defaultScroll: { align = 'left', offset = 0 } = {},
154
+ gap,
150
155
  children,
151
156
  ...props
152
157
  }: CarouselProps,
@@ -208,9 +213,27 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
208
213
  isFocusVisible: scrollerFocusVisible,
209
214
  } = useFocusRing()
210
215
 
216
+ // ナビゲーションボタン表示用。クリックで残留したフォーカス(pointer 由来)では
217
+ // 表示し続けないよう、キーボード由来のフォーカスのみを検知する。
218
+ const { focusProps: rootFocusProps, isFocusVisible: rootFocusVisible } =
219
+ useFocusRing({ within: true })
220
+
221
+ // gap 宣言自体は index.css 側に置き、ここでは CSS 変数の値だけを注入する。
222
+ const gapStyle = useMemo(
223
+ () =>
224
+ ({
225
+ ...(gap != null && {
226
+ '--charcoal-carousel-gap': typeof gap === 'number' ? `${gap}px` : gap,
227
+ }),
228
+ }) satisfies CSSProperties,
229
+ [gap],
230
+ )
231
+
211
232
  return (
212
233
  <div
234
+ {...rootFocusProps}
213
235
  className={className}
236
+ style={gapStyle}
214
237
  data-size={size}
215
238
  data-has-gradient={hasGradient}
216
239
  data-full-width={fullWidth}
@@ -219,6 +242,7 @@ const Carousel = forwardRef<CarouselHandlerRef, CarouselProps>(function Render(
219
242
  data-scroll-snap-align={snapAlign}
220
243
  data-can-prev={canPrev}
221
244
  data-can-next={canNext}
245
+ data-focus-visible-within={rootFocusVisible || undefined}
222
246
  role="region"
223
247
  aria-roledescription="carousel"
224
248
  aria-label="Carousel"
@@ -0,0 +1,38 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import { forwardRef } from 'react'
3
+ import TagItem from '.'
4
+
5
+ describe('TagItem', () => {
6
+ it('preserves link props on a custom link component', () => {
7
+ const NextLink = forwardRef<
8
+ HTMLAnchorElement,
9
+ React.ComponentPropsWithoutRef<'a'>
10
+ >(function NextLink({ children, ...props }, ref) {
11
+ return (
12
+ <a ref={ref} {...props}>
13
+ {children}
14
+ </a>
15
+ )
16
+ })
17
+
18
+ const onClick = vi.fn()
19
+
20
+ render(
21
+ <TagItem
22
+ component={NextLink}
23
+ href="/tags/girl"
24
+ label="#girl"
25
+ target="_blank"
26
+ onClick={onClick}
27
+ />,
28
+ )
29
+
30
+ const link = screen.getByRole('link')
31
+
32
+ expect(link).toHaveAttribute('href', '/tags/girl')
33
+ expect(link).toHaveAttribute('target', '_blank')
34
+
35
+ fireEvent.click(link)
36
+ expect(onClick).toHaveBeenCalledTimes(1)
37
+ })
38
+ })
@@ -61,6 +61,7 @@ const TagItem = forwardRef<HTMLAnchorElement, TagItemProps>(
61
61
 
62
62
  const { linkProps } = useLink(
63
63
  {
64
+ ...props,
64
65
  isDisabled: disabled,
65
66
  elementType: typeof Component === 'string' ? Component : 'a',
66
67
  },
@@ -30,6 +30,10 @@
30
30
  box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
31
31
  }
32
32
 
33
+ .charcoal-text-area-container:has([data-no-bottom-padding='true']) {
34
+ height: calc(22px * (var(--charcoal-text-area-rows) + 1) + 18px);
35
+ }
36
+
33
37
  .charcoal-text-area-container:not([aria-disabled='true']):hover {
34
38
  background-color: var(--charcoal-color-container-secondary-hover-a);
35
39
  }
@@ -47,13 +51,26 @@
47
51
  box-sizing: border-box;
48
52
 
49
53
  /* Prevent zooming for iOS Safari */
54
+ --charcoal-textarea-safari-prevent-zoom-scale: 0.875;
55
+ --charcoal-textarea-line-height: calc(
56
+ 22px / var(--charcoal-textarea-safari-prevent-zoom-scale)
57
+ );
58
+ --charcoal-textarea-padding-y: calc(
59
+ 8px / var(--charcoal-textarea-safari-prevent-zoom-scale)
60
+ );
61
+ --charcoal-textarea-padding-x: calc(
62
+ 9px / var(--charcoal-textarea-safari-prevent-zoom-scale)
63
+ );
50
64
  transform-origin: top left;
51
- transform: scale(0.875);
52
- width: calc(100% / 0.875);
53
- font-size: calc(14px / 0.875);
54
- line-height: calc(22px / 0.875);
55
- padding: calc(9px / 0.875) calc(8px / 0.875);
56
- height: calc(22px / 0.875 * var(--charcoal-text-area-rows) + 20px);
65
+ transform: scale(var(--charcoal-textarea-safari-prevent-zoom-scale));
66
+ width: calc(100% / var(--charcoal-textarea-safari-prevent-zoom-scale));
67
+ font-size: calc(14px / var(--charcoal-textarea-safari-prevent-zoom-scale));
68
+ line-height: var(--charcoal-textarea-line-height);
69
+ padding: var(--charcoal-textarea-padding-y) var(--charcoal-textarea-padding-x);
70
+ height: calc(
71
+ var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
72
+ var(--charcoal-textarea-padding-y) * 2
73
+ );
57
74
 
58
75
  /* Display box-shadow for iOS Safari */
59
76
  appearance: none;
@@ -61,8 +78,12 @@
61
78
  }
62
79
 
63
80
  .charcoal-text-area-textarea[data-no-bottom-padding='true'] {
64
- padding: calc(9px / 0.875) calc(8px / 0.875) 0;
65
- height: calc(22px / 0.875 * (var(--charcoal-text-area-rows) - 1) + 9px);
81
+ padding: var(--charcoal-textarea-padding-y)
82
+ var(--charcoal-textarea-padding-x) 0;
83
+ height: calc(
84
+ var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
85
+ var(--charcoal-textarea-padding-y)
86
+ );
66
87
  }
67
88
 
68
89
  .charcoal-text-area-textarea::placeholder {
@@ -32,6 +32,10 @@
32
32
  box-shadow: 0 0 0 4px rgba(0, 150, 250, 0.32);
33
33
  }
34
34
 
35
+ &:has([data-no-bottom-padding='true']) {
36
+ height: calc(22px * (var(--charcoal-text-area-rows) + 1) + 18px);
37
+ }
38
+
35
39
  &:not([aria-disabled='true']):hover {
36
40
  background-color: var(--charcoal-color-container-secondary-hover-a);
37
41
  }
@@ -50,13 +54,27 @@
50
54
  box-sizing: border-box;
51
55
 
52
56
  /* Prevent zooming for iOS Safari */
57
+ --charcoal-textarea-safari-prevent-zoom-scale: 0.875;
58
+ --charcoal-textarea-line-height: calc(
59
+ 22px / var(--charcoal-textarea-safari-prevent-zoom-scale)
60
+ );
61
+ --charcoal-textarea-padding-y: calc(
62
+ 8px / var(--charcoal-textarea-safari-prevent-zoom-scale)
63
+ );
64
+ --charcoal-textarea-padding-x: calc(
65
+ 9px / var(--charcoal-textarea-safari-prevent-zoom-scale)
66
+ );
67
+
53
68
  transform-origin: top left;
54
- transform: scale(0.875);
55
- width: calc(100% / 0.875);
56
- font-size: calc(14px / 0.875);
57
- line-height: calc(22px / 0.875);
58
- padding: calc(9px / 0.875) calc(8px / 0.875);
59
- height: calc(22px / 0.875 * var(--charcoal-text-area-rows) + 20px);
69
+ transform: scale(var(--charcoal-textarea-safari-prevent-zoom-scale));
70
+ width: calc(100% / var(--charcoal-textarea-safari-prevent-zoom-scale));
71
+ font-size: calc(14px / var(--charcoal-textarea-safari-prevent-zoom-scale));
72
+ line-height: var(--charcoal-textarea-line-height);
73
+ padding: var(--charcoal-textarea-padding-y) var(--charcoal-textarea-padding-x);
74
+ height: calc(
75
+ var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
76
+ var(--charcoal-textarea-padding-y) * 2
77
+ );
60
78
 
61
79
  /* Display box-shadow for iOS Safari */
62
80
  appearance: none;
@@ -64,8 +82,12 @@
64
82
  background: none;
65
83
 
66
84
  &[data-no-bottom-padding='true'] {
67
- padding: calc(9px / 0.875) calc(8px / 0.875) 0;
68
- height: calc(22px / 0.875 * (var(--charcoal-text-area-rows) - 1) + 9px);
85
+ padding: var(--charcoal-textarea-padding-y)
86
+ var(--charcoal-textarea-padding-x) 0;
87
+ height: calc(
88
+ var(--charcoal-textarea-line-height) * var(--charcoal-text-area-rows) +
89
+ var(--charcoal-textarea-padding-y)
90
+ );
69
91
  }
70
92
 
71
93
  &::placeholder {
@@ -206,7 +206,7 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
206
206
  aria-invalid={invalid === true}
207
207
  ref={containerRef}
208
208
  style={{
209
- '--charcoal-text-area-rows': `${showCount ? rows + 1 : rows}`,
209
+ '--charcoal-text-area-rows': rows,
210
210
  }}
211
211
  >
212
212
  <textarea