@charcoal-ui/react 1.0.0-alpha.1

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.
@@ -0,0 +1,386 @@
1
+ import { useTextField } from '@react-aria/textfield'
2
+ import { useVisuallyHidden } from '@react-aria/visually-hidden'
3
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
4
+ import styled, { css } from 'styled-components'
5
+ import FieldLabel, { FieldLabelProps } from '../FieldLabel'
6
+ import createTheme from '@charcoal-ui/styled'
7
+
8
+ const theme = createTheme(styled)
9
+
10
+ interface TextFieldBaseProps
11
+ extends Pick<FieldLabelProps, 'label' | 'requiredText' | 'subLabel'> {
12
+ readonly className?: string
13
+ readonly defaultValue?: string
14
+ readonly value?: string
15
+ readonly onChange?: (value: string) => void
16
+ readonly showCount?: boolean
17
+ readonly showLabel?: boolean
18
+ readonly placeholder?: string
19
+ readonly assistiveText?: string
20
+ readonly disabled?: boolean
21
+ readonly required?: boolean
22
+ readonly invalid?: boolean
23
+ readonly maxLength?: number
24
+ /**
25
+ * tab-indexがー1かどうか
26
+ */
27
+ readonly excludeFromTabOrder?: boolean
28
+ }
29
+
30
+ export interface SingleLineTextFieldProps extends TextFieldBaseProps {
31
+ readonly autoHeight?: never
32
+ readonly multiline?: false
33
+ readonly rows?: never
34
+ readonly type?: string
35
+ }
36
+
37
+ export interface MultiLineTextFieldProps extends TextFieldBaseProps {
38
+ readonly autoHeight?: boolean
39
+ readonly multiline: true
40
+ readonly rows?: number
41
+ readonly type?: never
42
+ }
43
+
44
+ export type TextFieldProps = SingleLineTextFieldProps | MultiLineTextFieldProps
45
+ type TextFieldElement = HTMLInputElement & HTMLTextAreaElement
46
+
47
+ function mergeRefs<T>(...refs: React.Ref<T>[]): React.RefCallback<T> {
48
+ return (value) => {
49
+ for (const ref of refs) {
50
+ if (typeof ref === 'function') {
51
+ ref(value)
52
+ } else if (ref !== null) {
53
+ ;(ref as React.MutableRefObject<T | null>).current = value
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ function countStringInCodePoints(string: string) {
60
+ return [...string].length
61
+ }
62
+
63
+ const TextField = React.forwardRef<TextFieldElement, TextFieldProps>(
64
+ function TextField(props, ref) {
65
+ return props.multiline !== undefined && props.multiline ? (
66
+ <MultiLineTextField ref={ref} {...props} />
67
+ ) : (
68
+ <SingleLineTextField ref={ref} {...props} />
69
+ )
70
+ }
71
+ )
72
+
73
+ export default TextField
74
+
75
+ const SingleLineTextField = React.forwardRef<
76
+ HTMLInputElement,
77
+ SingleLineTextFieldProps
78
+ >(function SingleLineTextFieldInner({ onChange, ...props }, forwardRef) {
79
+ const {
80
+ className,
81
+ showLabel = false,
82
+ showCount = false,
83
+ label,
84
+ requiredText,
85
+ subLabel,
86
+ disabled = false,
87
+ required,
88
+ invalid = false,
89
+ assistiveText,
90
+ maxLength,
91
+ } = props
92
+
93
+ const { visuallyHiddenProps } = useVisuallyHidden()
94
+ const ariaRef = useRef<HTMLInputElement>(null)
95
+ const [count, setCount] = useState(countStringInCodePoints(props.value ?? ''))
96
+
97
+ const handleChange = useCallback(
98
+ (value: string) => {
99
+ const count = countStringInCodePoints(value)
100
+ if (maxLength !== undefined && count > maxLength) {
101
+ return
102
+ }
103
+ setCount(count)
104
+ onChange?.(value)
105
+ },
106
+ [maxLength, onChange]
107
+ )
108
+
109
+ const { inputProps, labelProps, descriptionProps, errorMessageProps } =
110
+ useTextField(
111
+ {
112
+ inputElementType: 'input',
113
+ isDisabled: disabled,
114
+ isRequired: required,
115
+ validationState: invalid ? 'invalid' : 'valid',
116
+ description: !invalid && assistiveText,
117
+ errorMessage: invalid && assistiveText,
118
+ onChange: handleChange,
119
+ ...props,
120
+ },
121
+ ariaRef
122
+ )
123
+
124
+ return (
125
+ <TextFieldRoot className={className} isDisabled={disabled}>
126
+ <TextFieldLabel
127
+ label={label}
128
+ requiredText={requiredText}
129
+ required={required}
130
+ subLabel={subLabel}
131
+ {...labelProps}
132
+ {...(!showLabel ? visuallyHiddenProps : {})}
133
+ />
134
+ <StyledInputContainer>
135
+ <StyledInput
136
+ ref={mergeRefs(forwardRef, ariaRef)}
137
+ invalid={invalid}
138
+ {...inputProps}
139
+ />
140
+ {showCount && maxLength && (
141
+ <SingleLineCounter>
142
+ {count}/{maxLength}
143
+ </SingleLineCounter>
144
+ )}
145
+ </StyledInputContainer>
146
+ {assistiveText != null && assistiveText.length !== 0 && (
147
+ <AssistiveText
148
+ invalid={invalid}
149
+ {...(invalid ? errorMessageProps : descriptionProps)}
150
+ >
151
+ {assistiveText}
152
+ </AssistiveText>
153
+ )}
154
+ </TextFieldRoot>
155
+ )
156
+ })
157
+
158
+ const MultiLineTextField = React.forwardRef<
159
+ HTMLTextAreaElement,
160
+ MultiLineTextFieldProps
161
+ >(function MultiLineTextFieldInner({ onChange, ...props }, forwardRef) {
162
+ const {
163
+ className,
164
+ showCount = false,
165
+ showLabel = false,
166
+ label,
167
+ requiredText,
168
+ subLabel,
169
+ disabled = false,
170
+ required,
171
+ invalid = false,
172
+ assistiveText,
173
+ maxLength,
174
+ autoHeight = false,
175
+ rows: initialRows = 4,
176
+ } = props
177
+
178
+ const { visuallyHiddenProps } = useVisuallyHidden()
179
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
180
+ const ariaRef = useRef<HTMLTextAreaElement>(null)
181
+ const [count, setCount] = useState(countStringInCodePoints(props.value ?? ''))
182
+ const [rows, setRows] = useState(initialRows)
183
+
184
+ const syncHeight = useCallback(
185
+ (textarea: HTMLTextAreaElement) => {
186
+ const rows = `${textarea.value}\n`.match(/\n/gu)?.length ?? 1
187
+ if (initialRows <= rows) {
188
+ setRows(rows)
189
+ }
190
+ },
191
+ [initialRows]
192
+ )
193
+
194
+ const handleChange = useCallback(
195
+ (value: string) => {
196
+ const count = countStringInCodePoints(value)
197
+ if (maxLength !== undefined && count > maxLength) {
198
+ return
199
+ }
200
+ setCount(count)
201
+ if (autoHeight && textareaRef.current !== null) {
202
+ syncHeight(textareaRef.current)
203
+ }
204
+ onChange?.(value)
205
+ },
206
+ [autoHeight, maxLength, onChange, syncHeight]
207
+ )
208
+
209
+ const { inputProps, labelProps, descriptionProps, errorMessageProps } =
210
+ useTextField(
211
+ {
212
+ inputElementType: 'textarea',
213
+ isDisabled: disabled,
214
+ isRequired: required,
215
+ validationState: invalid ? 'invalid' : 'valid',
216
+ description: !invalid && assistiveText,
217
+ errorMessage: invalid && assistiveText,
218
+ onChange: handleChange,
219
+ ...props,
220
+ },
221
+ ariaRef
222
+ )
223
+
224
+ useEffect(() => {
225
+ if (autoHeight && textareaRef.current !== null) {
226
+ syncHeight(textareaRef.current)
227
+ }
228
+ }, [autoHeight, syncHeight])
229
+
230
+ return (
231
+ <TextFieldRoot className={className} isDisabled={disabled}>
232
+ <TextFieldLabel
233
+ label={label}
234
+ requiredText={requiredText}
235
+ required={required}
236
+ subLabel={subLabel}
237
+ {...labelProps}
238
+ {...(showLabel ? visuallyHiddenProps : {})}
239
+ />
240
+ <StyledTextareaContainer rows={rows}>
241
+ <StyledTextarea
242
+ ref={mergeRefs(textareaRef, forwardRef, ariaRef)}
243
+ invalid={invalid}
244
+ rows={rows}
245
+ {...inputProps}
246
+ />
247
+ {showCount && <MultiLineCounter>{count}</MultiLineCounter>}
248
+ </StyledTextareaContainer>
249
+ {assistiveText != null && assistiveText.length !== 0 && (
250
+ <AssistiveText
251
+ invalid={invalid}
252
+ {...(invalid ? errorMessageProps : descriptionProps)}
253
+ >
254
+ {assistiveText}
255
+ </AssistiveText>
256
+ )}
257
+ </TextFieldRoot>
258
+ )
259
+ })
260
+
261
+ const TextFieldRoot = styled.div<{ isDisabled: boolean }>`
262
+ display: flex;
263
+ flex-direction: column;
264
+
265
+ ${(p) => p.isDisabled && { opacity: p.theme.elementEffect.disabled.opacity }}
266
+ `
267
+
268
+ const TextFieldLabel = styled(FieldLabel)`
269
+ ${theme((o) => o.margin.bottom(8))}
270
+ `
271
+
272
+ const StyledInputContainer = styled.div`
273
+ height: 40px;
274
+ display: grid;
275
+ position: relative;
276
+ `
277
+
278
+ const StyledInput = styled.input<{ invalid: boolean }>`
279
+ border: none;
280
+ box-sizing: border-box;
281
+ outline: none;
282
+
283
+ /* Prevent zooming for iOS Safari */
284
+ transform-origin: top left;
285
+ transform: scale(0.875);
286
+ width: calc(100% / 0.875);
287
+ height: calc(40px / 0.875);
288
+ font-size: calc(14px / 0.875);
289
+ line-height: calc(22px / 0.875);
290
+ padding: calc(9px / 0.875) calc(8px / 0.875);
291
+ border-radius: calc(4px / 0.875);
292
+
293
+ /* Display box-shadow for iOS Safari */
294
+ appearance: none;
295
+
296
+ ${(p) =>
297
+ theme((o) => [
298
+ o.bg.surface3.hover,
299
+ o.outline.default.focus,
300
+ p.invalid && o.outline.assertive,
301
+ o.font.text2,
302
+ ])}
303
+
304
+ &::placeholder {
305
+ ${theme((o) => o.font.text3)}
306
+ }
307
+ `
308
+
309
+ const StyledTextareaContainer = styled.div<{ rows: number }>`
310
+ display: grid;
311
+ position: relative;
312
+
313
+ ${({ rows }) => css`
314
+ max-height: calc(22px * ${rows} + 18px);
315
+ `};
316
+ `
317
+
318
+ const StyledTextarea = styled.textarea<{ invalid: boolean }>`
319
+ border: none;
320
+ box-sizing: border-box;
321
+ outline: none;
322
+ resize: none;
323
+
324
+ /* Prevent zooming for iOS Safari */
325
+ transform-origin: top left;
326
+ transform: scale(0.875);
327
+ width: calc(100% / 0.875);
328
+ font-size: calc(14px / 0.875);
329
+ line-height: calc(22px / 0.875);
330
+ padding: calc(9px / 0.875) calc(8px / 0.875);
331
+ border-radius: calc(4px / 0.875);
332
+
333
+ ${({ rows }) => css`
334
+ height: calc(22px / 0.875 * ${rows} + 18px / 0.875);
335
+ `};
336
+
337
+ /* Display box-shadow for iOS Safari */
338
+ appearance: none;
339
+
340
+ ${(p) =>
341
+ theme((o) => [
342
+ o.bg.surface3.hover,
343
+ o.outline.default.focus,
344
+ p.invalid && o.outline.assertive,
345
+ o.font.text2,
346
+ ])}
347
+
348
+ &::placeholder {
349
+ ${theme((o) => o.font.text3)}
350
+ }
351
+
352
+ /* Hide scrollbar for Chrome, Safari and Opera */
353
+ &::-webkit-scrollbar {
354
+ display: none;
355
+ }
356
+ /* Hide scrollbar for IE, Edge and Firefox */
357
+ -ms-overflow-style: none; /* IE and Edge */
358
+ scrollbar-width: none; /* Firefox */
359
+ `
360
+
361
+ const SingleLineCounter = styled.span`
362
+ position: absolute;
363
+ top: 50%;
364
+ right: 8px;
365
+ transform: translateY(-50%);
366
+
367
+ ${theme((o) => [o.typography(14).preserveHalfLeading, o.font.text3])}
368
+ `
369
+
370
+ const MultiLineCounter = styled.span`
371
+ position: absolute;
372
+ bottom: 9px;
373
+ right: 8px;
374
+
375
+ ${theme((o) => [o.typography(14).preserveHalfLeading, o.font.text3])}
376
+ `
377
+
378
+ const AssistiveText = styled.p<{ invalid: boolean }>`
379
+ ${(p) =>
380
+ theme((o) => [
381
+ o.typography(14),
382
+ o.margin.top(8),
383
+ o.margin.bottom(0),
384
+ o.font[p.invalid ? 'assertive' : 'text1'],
385
+ ])}
386
+ `
@@ -0,0 +1,82 @@
1
+ import path from 'path'
2
+ import glob from 'glob'
3
+ import { axe, toHaveNoViolations } from 'jest-axe'
4
+ import React from 'react'
5
+ import { render } from '@testing-library/react'
6
+ import { ThemeProvider } from 'styled-components'
7
+ import { Story } from '../_lib/compat'
8
+ import ComponentAbstraction, { DefaultLink } from '../core/ComponentAbstraction'
9
+ import createTheme from '@charcoal-ui/styled'
10
+ const { light, dark } = createTheme
11
+
12
+ expect.extend(toHaveNoViolations)
13
+
14
+ const stories = glob
15
+ .sync(path.resolve(__dirname, '**/*.story.tsx'))
16
+ .flatMap((filename) =>
17
+ Object.entries(
18
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
19
+ require(`./${path.relative(__dirname, filename)}`) as Record<
20
+ string,
21
+ Story<any>
22
+ >
23
+ )
24
+ .filter(
25
+ ([exportName, story]) =>
26
+ exportName !== 'default' && typeof story === 'function'
27
+ )
28
+ .map<[string, string, Story<any>]>(([exportName, story]) => [
29
+ path.relative(__dirname, filename),
30
+ exportName,
31
+ story,
32
+ ])
33
+ )
34
+
35
+ const themes = Object.entries({
36
+ light,
37
+ dark,
38
+ })
39
+
40
+ const links = Object.entries({
41
+ DefaultLink,
42
+ })
43
+
44
+ const div = document.body.appendChild(document.createElement('div'))
45
+
46
+ beforeEach(() => {
47
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
48
+ // @ts-expect-error
49
+ global.__DEV__ = {}
50
+
51
+ global.IntersectionObserver = jest.fn().mockImplementation(() => ({
52
+ observe() {
53
+ return null
54
+ },
55
+ disconnect() {
56
+ return null
57
+ },
58
+ }))
59
+ })
60
+
61
+ describe.each(themes)('using %s theme', (_name, theme) => {
62
+ describe.each(links)('using %s component', (_name, link) => {
63
+ describe.each(stories)(
64
+ 'storiesOf(%s).add(%s)',
65
+ (_filename, _exportName, story) => {
66
+ it('has no accessibility violations', async () => {
67
+ expect(() => {
68
+ render(
69
+ <ThemeProvider theme={theme}>
70
+ <ComponentAbstraction components={{ Link: link }}>
71
+ {story(story.args)}
72
+ </ComponentAbstraction>
73
+ </ThemeProvider>
74
+ )
75
+ }).not.toThrow()
76
+
77
+ expect(await axe(div)).toHaveNoViolations()
78
+ })
79
+ }
80
+ )
81
+ })
82
+ })
@@ -0,0 +1,47 @@
1
+ import React, { useContext } from 'react'
2
+
3
+ export type LinkProps = {
4
+ /**
5
+ * リンクのURL
6
+ */
7
+ to: string
8
+ } & Omit<React.ComponentPropsWithoutRef<'a'>, 'href'>
9
+
10
+ export const DefaultLink = React.forwardRef<HTMLAnchorElement, LinkProps>(
11
+ function DefaultLink({ to, children, ...rest }, ref) {
12
+ return (
13
+ <a href={to} ref={ref} {...rest}>
14
+ {children}
15
+ </a>
16
+ )
17
+ }
18
+ )
19
+
20
+ interface Components {
21
+ Link: React.ComponentType<React.ComponentPropsWithRef<typeof DefaultLink>>
22
+ }
23
+
24
+ const DefaultValue: Components = {
25
+ Link: DefaultLink,
26
+ }
27
+
28
+ const ComponentAbstractionContext = React.createContext(DefaultValue)
29
+
30
+ interface Props {
31
+ children: React.ReactNode
32
+ components: Partial<Components>
33
+ }
34
+
35
+ export default function ComponentAbstraction({ children, components }: Props) {
36
+ return (
37
+ <ComponentAbstractionContext.Provider
38
+ value={{ ...DefaultValue, ...components }}
39
+ >
40
+ {children}
41
+ </ComponentAbstractionContext.Provider>
42
+ )
43
+ }
44
+
45
+ export function useComponentAbstraction() {
46
+ return useContext(ComponentAbstractionContext)
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export {
2
+ default as ComponentAbstraction,
3
+ useComponentAbstraction,
4
+ type LinkProps,
5
+ } from './core/ComponentAbstraction'
6
+ export { default as Button, type ButtonProps } from './components/Button'
7
+ export {
8
+ default as Clickable,
9
+ type ClickableProps,
10
+ type ClickableElement,
11
+ } from './components/Clickable'
12
+ export {
13
+ default as IconButton,
14
+ type IconButtonProps,
15
+ } from './components/IconButton'
16
+ export { default as Radio, type RadioProps } from './components/Radio'
17
+ export { default as Switch, type SwitchProps } from './components/Switch'
18
+ export {
19
+ default as TextField,
20
+ type TextFieldProps,
21
+ } from './components/TextField'
package/src/styled.ts ADDED
@@ -0,0 +1,3 @@
1
+ import styled from 'styled-components'
2
+ import createTheme from '@charcoal-ui/styled'
3
+ export const theme = createTheme(styled)
package/src/type.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { CSSProp, DefaultTheme } from 'styled-components'
2
+ import { ElementsTheme as Theme } from '@charcoal-ui/styled'
3
+
4
+ declare module 'styled-components' {
5
+ export interface DefaultTheme extends Theme {}
6
+ }
7
+
8
+ declare module 'react' {
9
+ interface Attributes {
10
+ css?: CSSProp<DefaultTheme>
11
+ }
12
+ }
13
+
14
+ declare global {
15
+ const __DEV__: object | undefined // actually object|false, but using undefined allows ! assertion
16
+ }