@charcoal-ui/react 6.1.0-beta.6 → 8.1.1-beta.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.
Files changed (33) hide show
  1. package/dist/components/DropdownSelector/Popover/index.d.ts.map +1 -1
  2. package/dist/components/DropdownSelector/Popover/index2.cjs +32 -1
  3. package/dist/components/DropdownSelector/Popover/index2.cjs.map +1 -1
  4. package/dist/components/DropdownSelector/Popover/index2.js +32 -1
  5. package/dist/components/DropdownSelector/Popover/index2.js.map +1 -1
  6. package/dist/components/Notification/NotificationRegion.cjs +3 -2
  7. package/dist/components/Notification/NotificationRegion.cjs.map +1 -1
  8. package/dist/components/Notification/NotificationRegion.d.ts.map +1 -1
  9. package/dist/components/Notification/NotificationRegion.js +3 -2
  10. package/dist/components/Notification/NotificationRegion.js.map +1 -1
  11. package/dist/components/Notification/types.d.ts +5 -0
  12. package/dist/components/Notification/types.d.ts.map +1 -1
  13. package/dist/components/TextField/index.d.ts +0 -2
  14. package/dist/components/TextField/index.d.ts.map +1 -1
  15. package/dist/components/TextField/index2.cjs.map +1 -1
  16. package/dist/components/TextField/index2.js.map +1 -1
  17. package/dist/index.css +587 -592
  18. package/dist/layered.css +587 -592
  19. package/dist/layered.css.map +1 -1
  20. package/package.json +3 -3
  21. package/src/components/DropdownSelector/Popover/index.tsx +60 -2
  22. package/src/components/DropdownSelector/index.browser.test.tsx +78 -3
  23. package/src/components/DropdownSelector/index.story.tsx +37 -0
  24. package/src/components/Notification/NotificationRegion.browser.test.tsx +120 -0
  25. package/src/components/Notification/NotificationRegion.tsx +3 -2
  26. package/src/components/Notification/Snackbar/__snapshots__/index.css.snap +3 -6
  27. package/src/components/Notification/Snackbar/index.css +5 -6
  28. package/src/components/Notification/Snackbar/index.story.tsx +0 -1
  29. package/src/components/Notification/Toast/__snapshots__/index.css.snap +3 -5
  30. package/src/components/Notification/Toast/index.css +5 -5
  31. package/src/components/Notification/Toast/index.story.tsx +0 -1
  32. package/src/components/Notification/types.ts +5 -0
  33. package/src/components/TextField/index.tsx +0 -1
@@ -11,7 +11,7 @@
11
11
  * iOS Safari は非インタラクティブな要素へのペン入力で click を合成しないため、
12
12
  * click に依存する react-aria の外側判定では overlay を閉じられない。
13
13
  */
14
- import { fireEvent, render, screen } from '@testing-library/react'
14
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
15
15
  import { vi } from 'vitest'
16
16
  import DropdownSelector from '.'
17
17
  import DropdownMenuItem from './DropdownMenuItem'
@@ -60,7 +60,7 @@ describe.each([
60
60
  ['inertWorkaround が無効', false],
61
61
  ['inertWorkaround が有効', true],
62
62
  ])('%s なとき', (_, inertWorkaround) => {
63
- it('Apple Pencil で overlay の外側をタップすると閉じる', () => {
63
+ it('Apple Pencil で overlay の外側をタップすると閉じる', async () => {
64
64
  renderSelector(inertWorkaround)
65
65
 
66
66
  penTap(screen.getByRole('button'))
@@ -68,7 +68,9 @@ describe.each([
68
68
 
69
69
  penTap(hitTestOutsidePopover())
70
70
 
71
- expect(popover()).toBeNull()
71
+ await waitFor(() => {
72
+ expect(popover()).toBeNull()
73
+ })
72
74
  })
73
75
 
74
76
  // 選択肢のタップは onChange 経由で閉じるのが仕様なので、余白部分を叩く
@@ -85,6 +87,79 @@ describe.each([
85
87
  })
86
88
  })
87
89
 
90
+ it('inertWorkaround が有効なとき、開いた直後に underlay へ送られる互換 click では閉じない', () => {
91
+ renderSelector(true)
92
+
93
+ penTap(screen.getByRole('button'))
94
+ expect(popover()).not.toBeNull()
95
+
96
+ // Android Chrome はペンの pointerup 後、underlay 上の pointerdown を伴わない
97
+ // mouse / click イベントを新しく追加された underlay に送る。
98
+ const underlay = getUnderlay()
99
+ fireEvent.mouseDown(underlay)
100
+ fireEvent.mouseUp(underlay)
101
+ fireEvent.click(underlay)
102
+
103
+ expect(popover()).not.toBeNull()
104
+ })
105
+
106
+ it('ペンで外側をタップしたとき、下にあるクリック可能な要素をクリックせずに閉じる', async () => {
107
+ const handleBackgroundClick = vi.fn()
108
+ render(
109
+ <>
110
+ <DropdownSelector
111
+ label="Label"
112
+ value="1"
113
+ onChange={vi.fn()}
114
+ inertWorkaround
115
+ >
116
+ <DropdownMenuItem value="1">Option 1</DropdownMenuItem>
117
+ <DropdownMenuItem value="2">Option 2</DropdownMenuItem>
118
+ </DropdownSelector>
119
+ <button
120
+ type="button"
121
+ onClick={handleBackgroundClick}
122
+ style={{ position: 'fixed', right: 16, bottom: 16 }}
123
+ >
124
+ Background button
125
+ </button>
126
+ </>,
127
+ )
128
+
129
+ penTap(screen.getByRole('button', { name: 'Label' }))
130
+ expect(popover()).not.toBeNull()
131
+
132
+ const backgroundButton = screen.getByRole('button', {
133
+ name: 'Background button',
134
+ })
135
+ const rect = backgroundButton.getBoundingClientRect()
136
+ const point = {
137
+ x: Math.round(rect.left + rect.width / 2),
138
+ y: Math.round(rect.top + rect.height / 2),
139
+ }
140
+ const pointerTarget = document.elementFromPoint(point.x, point.y)
141
+ if (pointerTarget === null) throw new Error('pointer target not found')
142
+
143
+ fireEvent.pointerDown(pointerTarget, {
144
+ pointerType: 'pen',
145
+ button: 0,
146
+ composed: true,
147
+ })
148
+ // Android Chrome はこの touchstart がキャンセルされた場合、後続の互換
149
+ // mouse / click イベントを生成しない。
150
+ expect(fireEvent.touchStart(pointerTarget)).toBe(false)
151
+ fireEvent.pointerUp(pointerTarget, {
152
+ pointerType: 'pen',
153
+ button: 0,
154
+ composed: true,
155
+ })
156
+
157
+ await waitFor(() => {
158
+ expect(popover()).toBeNull()
159
+ })
160
+ expect(handleBackgroundClick).not.toHaveBeenCalled()
161
+ })
162
+
88
163
  it('underlay は inertWorkaround が無効なとき inert になる', () => {
89
164
  renderSelector(false)
90
165
  penTap(screen.getByRole('button'))
@@ -543,3 +543,40 @@ export const WithRef: StoryObj<typeof DropdownSelector> = {
543
543
  )
544
544
  },
545
545
  }
546
+
547
+ export const InertWorkaroundPenOutside: StoryObj<typeof DropdownSelector> = {
548
+ render: function Render() {
549
+ const [selected, setSelected] = useState('1')
550
+ const [backgroundClickCount, setBackgroundClickCount] = useState(0)
551
+
552
+ return (
553
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 64 }}>
554
+ <div style={{ width: 288 }}>
555
+ <DropdownSelector
556
+ value={selected}
557
+ onChange={setSelected}
558
+ label="DropdownSelector"
559
+ inertWorkaround
560
+ >
561
+ <DropdownMenuItem value="1">Option 1</DropdownMenuItem>
562
+ <DropdownMenuItem value="2">Option 2</DropdownMenuItem>
563
+ <DropdownMenuItem value="3">Option 3</DropdownMenuItem>
564
+ </DropdownSelector>
565
+ </div>
566
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
567
+ <Button
568
+ data-testid="background-button"
569
+ onClick={() => {
570
+ setBackgroundClickCount((count) => count + 1)
571
+ }}
572
+ >
573
+ Background button
574
+ </Button>
575
+ <div data-testid="background-click-count">
576
+ Background click count: {backgroundClickCount}
577
+ </div>
578
+ </div>
579
+ </div>
580
+ )
581
+ },
582
+ }
@@ -0,0 +1,120 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import type { ComponentType } from 'react'
3
+ import { afterEach } from 'vitest'
4
+ import { useSnackbar } from './Snackbar'
5
+ import { useToast } from './Toast'
6
+
7
+ const HEADER_OFFSET = 64
8
+ const OFFSET = 24
9
+ const MINIMUM_OFFSET = 16
10
+
11
+ const notificationOptions = {
12
+ position: 'top',
13
+ headerOffset: HEADER_OFFSET,
14
+ duration: 60_000,
15
+ } as const
16
+
17
+ type AppProps = {
18
+ offset?: number
19
+ }
20
+
21
+ function ToastApp({ offset = OFFSET }: AppProps) {
22
+ const [toast, showToast] = useToast({ ...notificationOptions, offset })
23
+
24
+ return (
25
+ <>
26
+ <button
27
+ type="button"
28
+ onClick={() => showToast('保存しました', { type: 'success' })}
29
+ >
30
+ 通知を表示
31
+ </button>
32
+ <div style={{ height: '200vh' }}>{toast}</div>
33
+ </>
34
+ )
35
+ }
36
+
37
+ function SnackbarApp({ offset = OFFSET }: AppProps) {
38
+ const [snackbar, showSnackbar] = useSnackbar({
39
+ ...notificationOptions,
40
+ offset,
41
+ })
42
+
43
+ return (
44
+ <>
45
+ <button type="button" onClick={() => showSnackbar('保存しました')}>
46
+ 通知を表示
47
+ </button>
48
+ <div style={{ height: '200vh' }}>{snackbar}</div>
49
+ </>
50
+ )
51
+ }
52
+
53
+ function getRegions(regionSelector: string) {
54
+ const notificationRegion = screen.getByRole('region')
55
+ const itemRegion = notificationRegion.querySelector(regionSelector)
56
+
57
+ if (!(itemRegion instanceof HTMLElement)) {
58
+ throw new Error('Notification item region not found')
59
+ }
60
+
61
+ return { notificationRegion, itemRegion }
62
+ }
63
+
64
+ async function scrollTo(top: number) {
65
+ window.scrollTo(0, top)
66
+ await vi.waitFor(() => expect(window.scrollY).toBe(top))
67
+ }
68
+
69
+ afterEach(async () => {
70
+ await scrollTo(0)
71
+ })
72
+
73
+ describe.each<{
74
+ name: string
75
+ App: ComponentType<AppProps>
76
+ regionSelector: string
77
+ }>([
78
+ {
79
+ name: 'Toast',
80
+ App: ToastApp,
81
+ regionSelector: '.charcoal-toast-region',
82
+ },
83
+ {
84
+ name: 'Snackbar',
85
+ App: SnackbarApp,
86
+ regionSelector: '.charcoal-snackbar-region',
87
+ },
88
+ ])('$name header offset', ({ App, regionSelector }) => {
89
+ it('ヘッダーと一緒にスクロールし、画面上端からoffsetの位置で固定される', async () => {
90
+ render(<App />)
91
+ fireEvent.click(screen.getByRole('button', { name: '通知を表示' }))
92
+
93
+ const { notificationRegion, itemRegion } = getRegions(regionSelector)
94
+
95
+ expect(itemRegion.getBoundingClientRect().top).toBe(HEADER_OFFSET + OFFSET)
96
+
97
+ await scrollTo(20)
98
+
99
+ expect(notificationRegion.getBoundingClientRect().top).toBe(-20)
100
+ expect(itemRegion.getBoundingClientRect().top).toBe(
101
+ HEADER_OFFSET + OFFSET - 20,
102
+ )
103
+
104
+ await scrollTo(HEADER_OFFSET)
105
+
106
+ expect(notificationRegion.getBoundingClientRect().top).toBe(-HEADER_OFFSET)
107
+ expect(itemRegion.getBoundingClientRect().top).toBe(OFFSET)
108
+ })
109
+
110
+ it('offsetが16未満の場合は初期表示位置に16pxの余白を確保する', () => {
111
+ render(<App offset={8} />)
112
+ fireEvent.click(screen.getByRole('button', { name: '通知を表示' }))
113
+
114
+ const { itemRegion } = getRegions(regionSelector)
115
+
116
+ expect(itemRegion.getBoundingClientRect().top).toBe(
117
+ HEADER_OFFSET + MINIMUM_OFFSET,
118
+ )
119
+ })
120
+ })
@@ -48,6 +48,7 @@ export function NotificationRegion<TContent>({
48
48
  }
49
49
  const { regionProps } = useToastRegion({}, timerState, regionRef)
50
50
  const classNames = useClassNames('charcoal-notification-region', className)
51
+ const effectiveOffset = Math.max(offset, DEFAULT_OFFSET)
51
52
 
52
53
  if (state.visibleToasts.length === 0) {
53
54
  return null
@@ -62,14 +63,14 @@ export function NotificationRegion<TContent>({
62
63
  style={
63
64
  {
64
65
  zIndex,
65
- [`--charcoal-${name}-offset`]: `${offset}px`,
66
+ [`--charcoal-${name}-offset`]: `${effectiveOffset}px`,
66
67
  justifyContent: position === 'top' ? 'flex-start' : 'flex-end',
67
68
  } as CSSProperties
68
69
  }
69
70
  >
70
71
  <div
71
72
  style={{
72
- height: `${headerOffset}px`,
73
+ height: `calc(${headerOffset}px + env(safe-area-inset-top, 0px) + var(--charcoal-${name}-offset))`,
73
74
  }}
74
75
  />
75
76
  <div data-position={position} className={`charcoal-${name}-region`}>
@@ -1,21 +1,18 @@
1
- .charcoal-snackbar-region {
1
+ .charcoal-notification-region {
2
2
  --charcoal-snackbar-offset: 16px;
3
3
  }
4
4
 
5
5
  .charcoal-snackbar-region[data-position='top'] {
6
6
  --charcoal-snackbar-enter-offset: calc(-1 * var(--charcoal-space-30));
7
7
  position: sticky;
8
- top: calc(
9
- env(safe-area-inset-top, 0px) + max(var(--charcoal-snackbar-offset), 16px)
10
- );
8
+ top: calc(env(safe-area-inset-top, 0px) + var(--charcoal-snackbar-offset));
11
9
  }
12
10
 
13
11
  .charcoal-snackbar-region[data-position='bottom'] {
14
12
  --charcoal-snackbar-enter-offset: var(--charcoal-space-30);
15
13
  position: fixed;
16
14
  bottom: calc(
17
- env(safe-area-inset-bottom, 0px) +
18
- max(var(--charcoal-snackbar-offset), 16px)
15
+ env(safe-area-inset-bottom, 0px) + var(--charcoal-snackbar-offset)
19
16
  );
20
17
  }
21
18
 
@@ -1,13 +1,13 @@
1
- .charcoal-snackbar-region {
1
+ .charcoal-notification-region {
2
2
  --charcoal-snackbar-offset: 16px;
3
+ }
3
4
 
5
+ .charcoal-snackbar-region {
4
6
  &[data-position='top'] {
5
7
  --charcoal-snackbar-enter-offset: calc(-1 * var(--charcoal-space-30));
6
8
 
7
9
  position: sticky;
8
- top: calc(
9
- env(safe-area-inset-top, 0px) + max(var(--charcoal-snackbar-offset), 16px)
10
- );
10
+ top: calc(env(safe-area-inset-top, 0px) + var(--charcoal-snackbar-offset));
11
11
  }
12
12
 
13
13
  &[data-position='bottom'] {
@@ -15,8 +15,7 @@
15
15
 
16
16
  position: fixed;
17
17
  bottom: calc(
18
- env(safe-area-inset-bottom, 0px) +
19
- max(var(--charcoal-snackbar-offset), 16px)
18
+ env(safe-area-inset-bottom, 0px) + var(--charcoal-snackbar-offset)
20
19
  );
21
20
  }
22
21
  }
@@ -34,7 +34,6 @@ export default {
34
34
  args: {
35
35
  message: '保存しました',
36
36
  position: 'bottom',
37
- offset: 16,
38
37
  duration: 5000,
39
38
  order: 'queue',
40
39
  dim: false,
@@ -1,20 +1,18 @@
1
- .charcoal-toast-region {
1
+ .charcoal-notification-region {
2
2
  --charcoal-toast-offset: 16px;
3
3
  }
4
4
 
5
5
  .charcoal-toast-region[data-position='top'] {
6
6
  position: sticky;
7
7
  --charcoal-toast-enter-offset: calc(-1 * var(--charcoal-space-30));
8
- top: calc(
9
- env(safe-area-inset-top, 0px) + max(var(--charcoal-toast-offset), 16px)
10
- );
8
+ top: calc(env(safe-area-inset-top, 0px) + var(--charcoal-toast-offset));
11
9
  }
12
10
 
13
11
  .charcoal-toast-region[data-position='bottom'] {
14
12
  position: fixed;
15
13
  --charcoal-toast-enter-offset: var(--charcoal-space-30);
16
14
  bottom: calc(
17
- env(safe-area-inset-bottom, 0px) + max(var(--charcoal-toast-offset), 16px)
15
+ env(safe-area-inset-bottom, 0px) + var(--charcoal-toast-offset)
18
16
  );
19
17
  }
20
18
 
@@ -1,13 +1,13 @@
1
- .charcoal-toast-region {
1
+ .charcoal-notification-region {
2
2
  --charcoal-toast-offset: 16px;
3
+ }
3
4
 
5
+ .charcoal-toast-region {
4
6
  &[data-position='top'] {
5
7
  position: sticky;
6
8
  --charcoal-toast-enter-offset: calc(-1 * var(--charcoal-space-30));
7
9
 
8
- top: calc(
9
- env(safe-area-inset-top, 0px) + max(var(--charcoal-toast-offset), 16px)
10
- );
10
+ top: calc(env(safe-area-inset-top, 0px) + var(--charcoal-toast-offset));
11
11
  }
12
12
 
13
13
  &[data-position='bottom'] {
@@ -15,7 +15,7 @@
15
15
  --charcoal-toast-enter-offset: var(--charcoal-space-30);
16
16
 
17
17
  bottom: calc(
18
- env(safe-area-inset-bottom, 0px) + max(var(--charcoal-toast-offset), 16px)
18
+ env(safe-area-inset-bottom, 0px) + var(--charcoal-toast-offset)
19
19
  );
20
20
  }
21
21
  }
@@ -34,7 +34,6 @@ export default {
34
34
  args: {
35
35
  message: '保存しました',
36
36
  position: 'top',
37
- offset: 16,
38
37
  duration: 5000,
39
38
  order: 'queue',
40
39
  zIndex: 20,
@@ -9,6 +9,11 @@ export type UseNotificationOptions = {
9
9
  * @default 16
10
10
  */
11
11
  offset?: number
12
+ /**
13
+ * スクロールに追従しないヘッダーの高さを指定する
14
+ * @default 0
15
+ */
16
+ headerOffset?: number
12
17
  /**
13
18
  * 通知を表示する時間(ミリ秒)。負の値は 0、数値でない値は 5000 として扱う
14
19
  * @default 5000
@@ -26,7 +26,6 @@ export type TextFieldProps = {
26
26
  requiredText?: string
27
27
  disabled?: boolean
28
28
  subLabel?: React.ReactNode
29
- rdfaPrefix?: string
30
29
 
31
30
  getCount?: (value: string) => number
32
31
  } & Omit<React.ComponentPropsWithoutRef<'input'>, 'prefix' | 'onChange'>