@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,108 @@
1
+ import React from 'react'
2
+ import styled, { css } from 'styled-components'
3
+ import {
4
+ LinkProps,
5
+ useComponentAbstraction,
6
+ } from '../../core/ComponentAbstraction'
7
+ import { disabledSelector } from '@charcoal-ui/utils'
8
+
9
+ interface BaseProps {
10
+ /**
11
+ * クリックの無効化
12
+ */
13
+ disabled?: boolean
14
+ }
15
+
16
+ interface LinkBaseProps {
17
+ /**
18
+ * リンクのURL。指定するとbuttonタグではなくaタグとして描画される
19
+ */
20
+ to: string
21
+ }
22
+
23
+ export type ClickableProps =
24
+ | (BaseProps & Omit<React.ComponentPropsWithoutRef<'button'>, 'disabled'>)
25
+ | (BaseProps & LinkBaseProps & Omit<LinkProps, 'to'>)
26
+ export type ClickableElement = HTMLButtonElement & HTMLAnchorElement
27
+
28
+ const Clickable = React.forwardRef<ClickableElement, ClickableProps>(
29
+ function Clickable(props, ref) {
30
+ const { Link } = useComponentAbstraction()
31
+ if ('to' in props) {
32
+ const { onClick, disabled = false, ...rest } = props
33
+ return (
34
+ <A<typeof Link>
35
+ {...rest}
36
+ as={disabled ? undefined : Link}
37
+ onClick={disabled ? undefined : onClick}
38
+ aria-disabled={disabled}
39
+ ref={ref}
40
+ />
41
+ )
42
+ } else {
43
+ return <Button {...props} ref={ref} />
44
+ }
45
+ }
46
+ )
47
+ export default Clickable
48
+
49
+ const clickableCss = css`
50
+ /* Clickable style */
51
+ cursor: pointer;
52
+
53
+ ${disabledSelector} {
54
+ cursor: default;
55
+ }
56
+ `
57
+
58
+ const Button = styled.button`
59
+ /* Reset button appearance */
60
+ appearance: none;
61
+ background: transparent;
62
+ padding: 0;
63
+ border-style: none;
64
+ outline: none;
65
+ color: inherit;
66
+ text-rendering: inherit;
67
+ letter-spacing: inherit;
68
+ word-spacing: inherit;
69
+
70
+ &:focus {
71
+ outline: none;
72
+ }
73
+
74
+ /* Change the font styles in all browsers. */
75
+ font: inherit;
76
+
77
+ /* Remove the margin in Firefox and Safari. */
78
+ margin: 0;
79
+
80
+ /* Show the overflow in Edge. */
81
+ overflow: visible;
82
+
83
+ /* Remove the inheritance of text transform in Firefox. */
84
+ text-transform: none;
85
+
86
+ /* Remove the inner border and padding in Firefox. */
87
+ &::-moz-focus-inner {
88
+ border-style: none;
89
+ padding: 0;
90
+ }
91
+
92
+ ${clickableCss}
93
+ `
94
+
95
+ const A = styled.span`
96
+ /* Reset a-tag appearance */
97
+ color: inherit;
98
+
99
+ &:focus {
100
+ outline: none;
101
+ }
102
+
103
+ .text {
104
+ top: calc(1em + 2em);
105
+ }
106
+
107
+ ${clickableCss}
108
+ `
@@ -0,0 +1,73 @@
1
+ import React from 'react'
2
+ import styled from 'styled-components'
3
+ import createTheme from '@charcoal-ui/styled'
4
+
5
+ export interface FieldLabelProps
6
+ extends React.LabelHTMLAttributes<HTMLLabelElement> {
7
+ readonly className?: string
8
+ readonly label: string
9
+ readonly subLabel?: React.ReactNode
10
+ readonly required?: boolean
11
+ // TODO: 翻訳用のContextで注入する
12
+ readonly requiredText?: string
13
+ }
14
+
15
+ const FieldLabel = React.forwardRef<HTMLLabelElement, FieldLabelProps>(
16
+ function FieldLabel(
17
+ {
18
+ style,
19
+ className,
20
+ label,
21
+ required = false,
22
+ requiredText,
23
+ subLabel,
24
+ ...labelProps
25
+ },
26
+ ref
27
+ ) {
28
+ return (
29
+ <FieldLabelWrapper style={style} className={className}>
30
+ <Label ref={ref} {...labelProps}>
31
+ {label}
32
+ </Label>
33
+ {required && <RequiredText>{requiredText}</RequiredText>}
34
+ <SubLabelClickable>
35
+ <span>{subLabel}</span>
36
+ </SubLabelClickable>
37
+ </FieldLabelWrapper>
38
+ )
39
+ }
40
+ )
41
+
42
+ export default FieldLabel
43
+
44
+ const theme = createTheme(styled)
45
+
46
+ const Label = styled.label`
47
+ ${theme((o) => [o.typography(14).bold, o.font.text1])}
48
+ `
49
+
50
+ const RequiredText = styled.span`
51
+ ${theme((o) => [o.typography(14), o.font.text3])}
52
+ `
53
+
54
+ const SubLabelClickable = styled.div`
55
+ ${theme((o) => [
56
+ o.typography(14),
57
+ o.font.text3.hover.press,
58
+ o.outline.default.focus,
59
+ ])}
60
+ `
61
+
62
+ const FieldLabelWrapper = styled.div`
63
+ display: inline-flex;
64
+ align-items: center;
65
+
66
+ > ${RequiredText} {
67
+ ${theme((o) => o.margin.left(4))}
68
+ }
69
+
70
+ > ${SubLabelClickable} {
71
+ ${theme((o) => o.margin.left('auto'))}
72
+ }
73
+ `
@@ -0,0 +1,51 @@
1
+ import React from 'react'
2
+ import type { Story } from '../../_lib/compat'
3
+ import '@charcoal-ui/icons'
4
+ import IconButton from '.'
5
+ import type { KnownIconType } from '@charcoal-ui/icons'
6
+
7
+ export default {
8
+ title: 'IconButton',
9
+ component: IconButton,
10
+ argTypes: {
11
+ variant: {
12
+ control: {
13
+ type: 'inline-radio',
14
+ options: ['Default', 'Overlay'],
15
+ },
16
+ },
17
+ size: {
18
+ control: {
19
+ type: 'inline-radio',
20
+ options: ['M', 'S', 'XS'],
21
+ },
22
+ },
23
+ },
24
+ }
25
+
26
+ type Props = {
27
+ variant?: 'Default' | 'Overlay'
28
+ size?: 'M' | 'S' | 'XS'
29
+ }
30
+
31
+ const Template: Story<Props> = (props) => {
32
+ const { size } = props
33
+ const icon: keyof KnownIconType = {
34
+ XS: '16/Remove' as const,
35
+ S: '24/Close' as const,
36
+ M: '24/Close' as const,
37
+ }[size ?? 'M']
38
+ return <IconButton title="close" {...props} icon={icon} />
39
+ }
40
+
41
+ export const DefaultM: Story<Props> = Template.bind({})
42
+ DefaultM.args = {
43
+ variant: 'Default',
44
+ size: 'M',
45
+ }
46
+
47
+ export const OverlayM: Story<Props> = Template.bind({})
48
+ OverlayM.args = {
49
+ variant: 'Overlay',
50
+ size: 'M',
51
+ }
@@ -0,0 +1,118 @@
1
+ import React from 'react'
2
+ import styled from 'styled-components'
3
+ import { theme } from '../../styled'
4
+ import Clickable, { ClickableElement, ClickableProps } from '../Clickable'
5
+ import type { KnownIconType } from '@charcoal-ui/icons'
6
+
7
+ type Variant = 'Default' | 'Overlay'
8
+ type Size = 'XS' | 'S' | 'M'
9
+
10
+ interface StyledProps {
11
+ readonly variant?: Variant
12
+ readonly size?: Size
13
+ readonly icon: keyof KnownIconType
14
+ }
15
+
16
+ export type IconButtonProps = StyledProps & ClickableProps
17
+
18
+ const IconButton = React.forwardRef<ClickableElement, IconButtonProps>(
19
+ function IconButtonInner(
20
+ { variant = 'Default', size = 'M', icon, ...rest }: IconButtonProps,
21
+ ref
22
+ ) {
23
+ validateIconSize(size, icon)
24
+ return (
25
+ <StyledIconButton {...rest} ref={ref} variant={variant} size={size}>
26
+ <pixiv-icon name={icon} />
27
+ </StyledIconButton>
28
+ )
29
+ }
30
+ )
31
+
32
+ export default IconButton
33
+
34
+ const StyledIconButton = styled(Clickable).attrs<
35
+ Required<StyledProps>,
36
+ ReturnType<typeof styledProps>
37
+ >(styledProps)`
38
+ user-select: none;
39
+
40
+ width: ${(p) => p.width}px;
41
+ height: ${(p) => p.height}px;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+
46
+ ${({ font, background }) =>
47
+ theme((o) => [
48
+ o.font[font],
49
+ o.bg[background].hover.press,
50
+ o.disabled,
51
+ o.borderRadius('oval'),
52
+ o.outline.default.focus,
53
+ ])}
54
+ `
55
+
56
+ function styledProps(props: Required<StyledProps>) {
57
+ return {
58
+ ...props,
59
+ ...variantToProps(props.variant),
60
+ ...sizeToProps(props.size),
61
+ }
62
+ }
63
+
64
+ function variantToProps(variant: Variant) {
65
+ switch (variant) {
66
+ case 'Default':
67
+ return { font: 'text3', background: 'transparent' } as const
68
+ case 'Overlay':
69
+ return { font: 'text5', background: 'surface4' } as const
70
+ }
71
+ }
72
+
73
+ function sizeToProps(size: Size) {
74
+ switch (size) {
75
+ case 'XS':
76
+ return {
77
+ width: 20,
78
+ height: 20,
79
+ }
80
+ case 'S':
81
+ return {
82
+ width: 32,
83
+ height: 32,
84
+ }
85
+ case 'M':
86
+ return {
87
+ width: 40,
88
+ height: 40,
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * validates matches of size and icon
95
+ */
96
+ function validateIconSize(size: Size, icon: keyof KnownIconType) {
97
+ let requiredIconSize: string
98
+ switch (size) {
99
+ case 'XS':
100
+ requiredIconSize = '16'
101
+ break
102
+ case 'S':
103
+ case 'M':
104
+ requiredIconSize = '24'
105
+ break
106
+ }
107
+ // アイコン名は サイズ/名前
108
+ const result = /^\d*/u.exec(icon)
109
+ if (result == null) {
110
+ throw new Error('Invalid icon name')
111
+ }
112
+ const [iconSize] = result
113
+ if (iconSize !== requiredIconSize) {
114
+ console.warn(
115
+ `IconButton with size "${size}" expect icon size "${requiredIconSize}, but got "${iconSize}"`
116
+ )
117
+ }
118
+ }
@@ -0,0 +1,79 @@
1
+ import { action } from '@storybook/addon-actions'
2
+ import React from 'react'
3
+ import { css } from 'styled-components'
4
+ import { Story } from '../../_lib/compat'
5
+ import Radio, { RadioGroup } from '.'
6
+ import { px } from '@charcoal-ui/utils'
7
+
8
+ const options = ['value1', 'value2']
9
+
10
+ export default {
11
+ title: 'Radio',
12
+ component: Radio,
13
+ argTypes: {
14
+ defaultValue: {
15
+ control: { type: 'select', options },
16
+ },
17
+ },
18
+ }
19
+
20
+ interface Props {
21
+ defaultValue: string
22
+ hasError: boolean
23
+ parentDisabled: boolean
24
+ childDisabled: boolean
25
+ forceChecked: boolean
26
+ readonly: boolean
27
+ }
28
+
29
+ const DefaultStory = ({
30
+ defaultValue,
31
+ forceChecked,
32
+ hasError,
33
+ parentDisabled,
34
+ childDisabled,
35
+ readonly,
36
+ }: Props) => (
37
+ <div
38
+ css={css`
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: ${({ theme }) => px(theme.spacing[24])};
42
+ `}
43
+ >
44
+ {['name1', 'name2', 'name3'].map((name) => (
45
+ <RadioGroup
46
+ key={name}
47
+ label={`選択肢-${name}`}
48
+ name={name}
49
+ defaultValue={defaultValue}
50
+ onChange={action('onChange')}
51
+ disabled={parentDisabled}
52
+ readonly={readonly}
53
+ hasError={hasError}
54
+ >
55
+ {options.map((option) => (
56
+ <Radio
57
+ key={option}
58
+ value={option}
59
+ disabled={childDisabled}
60
+ forceChecked={forceChecked}
61
+ >
62
+ {name}({option})を選ぶ
63
+ </Radio>
64
+ ))}
65
+ </RadioGroup>
66
+ ))}
67
+ </div>
68
+ )
69
+
70
+ export const Normal: Story<Props> = DefaultStory.bind({})
71
+
72
+ Normal.args = {
73
+ defaultValue: options[0],
74
+ hasError: false,
75
+ parentDisabled: false,
76
+ childDisabled: false,
77
+ forceChecked: false,
78
+ readonly: false,
79
+ }
@@ -0,0 +1,149 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import React from 'react'
3
+ import { ThemeProvider } from 'styled-components'
4
+ import Radio, { RadioGroup } from '.'
5
+ import createTheme from '@charcoal-ui/styled'
6
+ const { light } = createTheme
7
+
8
+ describe('Radio', () => {
9
+ describe('__DEV__ mode', () => {
10
+ beforeEach(() => {
11
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
12
+ // @ts-expect-error
13
+ global.__DEV__ = {}
14
+ })
15
+
16
+ describe('<Radio> is not surrounded by <RadioGroup>', () => {
17
+ beforeEach(() => {
18
+ console.error = jest.fn()
19
+
20
+ render(
21
+ <ThemeProvider theme={light}>
22
+ <Radio value="option1" />
23
+ </ThemeProvider>
24
+ )
25
+ })
26
+
27
+ it('console.error()', () => {
28
+ expect(console.error).toHaveBeenCalledWith(
29
+ expect.stringMatching(/Perhaps you forgot to wrap with <RadioGroup>/u)
30
+ )
31
+ })
32
+ })
33
+ })
34
+
35
+ describe('defaultValue is the first option', () => {
36
+ let option1: HTMLInputElement
37
+ let option2: HTMLInputElement
38
+
39
+ beforeEach(() => {
40
+ render(<TestComponent defaultValue="option1" />)
41
+
42
+ option1 = screen.getByDisplayValue('option1')
43
+ option2 = screen.getByDisplayValue('option2')
44
+ })
45
+
46
+ it('the first <Radio> is checked', () => {
47
+ expect(option1.checked).toBeTruthy()
48
+ expect(option2.checked).toBeFalsy()
49
+ })
50
+
51
+ describe('clicking the second', () => {
52
+ it('the second <Radio> is checked', () => {
53
+ fireEvent.click(option2)
54
+
55
+ expect(option1.checked).toBeFalsy()
56
+ expect(option2.checked).toBeTruthy()
57
+ })
58
+ })
59
+ })
60
+
61
+ describe('<RadioGroup> is disabled', () => {
62
+ it('all <Radio>s are disabled', () => {
63
+ render(<TestComponent defaultValue="option1" radioGroupDisabled />)
64
+ screen.getAllByRole<HTMLInputElement>('radio').forEach((element) => {
65
+ expect(element.disabled).toBeTruthy()
66
+ })
67
+ })
68
+ })
69
+
70
+ describe('the first <Radio> is disabled', () => {
71
+ let option1: HTMLInputElement
72
+ let option2: HTMLInputElement
73
+
74
+ beforeEach(() => {
75
+ render(<TestComponent defaultValue="option1" option1Disabled />)
76
+
77
+ option1 = screen.getByDisplayValue('option1')
78
+ option2 = screen.getByDisplayValue('option2')
79
+ })
80
+
81
+ it('<input> in the first <Radio> is disabled', () => {
82
+ expect(option1.checked).toBeTruthy()
83
+ })
84
+
85
+ it('No other <input> is disabled', () => {
86
+ expect(option2.checked).toBeFalsy()
87
+ })
88
+ })
89
+
90
+ describe('has readonly in <RadioGroup>', () => {
91
+ let option1: HTMLInputElement
92
+ let option2: HTMLInputElement
93
+
94
+ beforeEach(() => {
95
+ render(<TestComponent defaultValue="option1" readonly />)
96
+
97
+ option1 = screen.getByDisplayValue('option1')
98
+ option2 = screen.getByDisplayValue('option2')
99
+ })
100
+
101
+ it('the first <Radio> is checked', () => {
102
+ expect(option1.checked).toBeTruthy()
103
+ expect(option1.disabled).toBeFalsy()
104
+ })
105
+
106
+ it('Non-first <Radio>s are disabled', () => {
107
+ expect(option2.disabled).toBeTruthy()
108
+ })
109
+ })
110
+ })
111
+
112
+ function TestComponent({
113
+ defaultValue,
114
+ onChange = jest.fn(),
115
+ radioGroupDisabled = false,
116
+ readonly = false,
117
+ hasError = false,
118
+ option1Disabled = false,
119
+ option2Disabled = false,
120
+ }: {
121
+ defaultValue: string
122
+ onChange?: () => void
123
+ radioGroupDisabled?: boolean
124
+ readonly?: boolean
125
+ hasError?: boolean
126
+ option1Disabled?: boolean
127
+ option2Disabled?: boolean
128
+ }) {
129
+ return (
130
+ <ThemeProvider theme={light}>
131
+ <RadioGroup
132
+ label="テスト項目"
133
+ name="test"
134
+ defaultValue={defaultValue}
135
+ onChange={onChange}
136
+ disabled={radioGroupDisabled}
137
+ readonly={readonly}
138
+ hasError={hasError}
139
+ >
140
+ <Radio value="option1" disabled={option1Disabled}>
141
+ option1を選ぶ
142
+ </Radio>
143
+ <Radio value="option2" disabled={option2Disabled}>
144
+ option2を選ぶ
145
+ </Radio>
146
+ </RadioGroup>
147
+ </ThemeProvider>
148
+ )
149
+ }