@charcoal-ui/icons 6.0.0-beta.0 → 6.0.0-beta.2

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/src/SSR.mdx CHANGED
@@ -8,58 +8,142 @@ import { Meta, Story, Canvas } from '@storybook/addon-docs/blocks'
8
8
  `@charcoal-ui/icons` は、Node.js の環境下で import されたり、API を呼び出されても問題が起きないように設計されています。
9
9
 
10
10
  一方、Custom Element であるということは、**SVG アイコンの読み込みはクライアントサイドで行われます**。
11
- したがって、大きさが確定せずにレイアウトシフトが起こりうるのは、SSR における注意点の一つと言えます。
12
-
13
- 以下のようなコードをリセット CSS に含めることで、`<pixiv-icon>` によるレイアウトシフトの発生を防ぐことができます。
14
- (簡単のため、ネスト記法が利用できるケースを意図しています)
15
-
16
- ```css
17
- pixiv-icon {
18
- display: inline-flex;
19
- width: calc(var(--icon-size, 1em) * var(--scale, 1));
20
- height: calc(var(--icon-size, 1em) * var(--scale, 1));
21
- }
22
-
23
- &[name~='16/'] {
24
- --icon-size: 16px;
25
- }
26
-
27
- &[name~='24/'] {
28
- --icon-size: 24px;
29
- }
30
-
31
- &[name~='32/'] {
32
- --icon-size: 32px;
33
- }
34
-
35
- &[scale='2'] {
36
- --scale: 2;
37
- }
38
-
39
- &[scale='3'] {
40
- --scale: 3;
41
- }
42
-
43
- /* NOTICE: 現状とサポートブラウザが少ない */
44
- @supports (--scale: attr(unsafe-non-guideline-scale)) {
45
- &[unsafe-non-guideline-scale] {
46
- --scale: attr(unsafe-non-guideline-scale);
47
- }
48
- }
11
+ したがって、大きさが確定せずにレイアウトシフト (CLS) が起こりうるのは、SSR における注意点の一つと言えます。
12
+
13
+ ## なぜ CSS が必要なのか
14
+
15
+ `@charcoal-ui/icons` の JS が読み込まれて `customElements.define('pixiv-icon', ...)` が実行されるまで、`<pixiv-icon>` は Shadow DOM を持たない単なる未登録 Custom Element です。CSS がない場合、ブラウザはこの要素のサイズを 0x0 として扱うため:
16
+
17
+ - SSR で送出された HTML が画面に描画された直後: アイコンは **0x0** で何も見えない
18
+ - JS がロードされて `pixiv-icon` が `:defined` になった瞬間: 突然 16x16 / 24x24 などの実サイズになり、**周囲のコンテンツが押し出される**
19
+
20
+ この瞬間に発生する CLS を、`icon.css` が事前に寸法を確保することで防ぎます。
21
+
22
+ ## レイアウトシフト防止 CSS
23
+
24
+ `@charcoal-ui/icons/css/icon.css` をインポートすると、`<pixiv-icon>` のレイアウトシフトを防止できます。
25
+ アプリケーションのエントリポイント、または Storybook の `preview.(js|ts)` で 1 度だけ読み込んでください。
26
+
27
+ ```ts
28
+ import '@charcoal-ui/icons/css/icon.css'
49
29
  ```
50
30
 
51
- ただしコメントにもある通り、**現状 `unsafe-non-guideline-scale` をつけた要素は、リセット CSS だけではレイアウトシフトが防げません。**
52
- CSS の `attr()` が数値の解釈をサポートするまで、個別にインラインスタイルを用いて大きさを確定させるなどのワークアラウンドが必要です。
31
+ この CSS 2 つのメカニズムを提供します。
32
+
33
+ ### 1. `.charcoal-icon` クラス(React 向け)
34
+
35
+ `@charcoal-ui/react` の `<Icon>` コンポーネントは自動的に `.charcoal-icon` クラスと `--charcoal-icon-size` CSS 変数を設定します。
36
+ CSS が読み込まれていれば、Custom Element の定義前でも正しいサイズが確保されます。
37
+
38
+ ### 2. `pixiv-icon:not(:defined)` セレクター(vanilla HTML 向け)
39
+
40
+ React を使わない場合でも、`name` 属性のプレフィックス(`16/`, `24/`, `32/`, `Inline/`)と `scale` 属性に基づいてサイズが確保されます。
41
+
42
+ ## サイズ決定ルール
43
+
44
+ サイズの決定タイミングは hydrate の前後で異なります。
45
+
46
+ ### hydrate 前 (`pixiv-icon:not(:defined)` の段階)
47
+
48
+ 1. **`style="--charcoal-icon-size: Npx"` が指定されていれば、その値を使う**(最優先)
49
+ 2. それ以外は `name` プレフィックスと `scale` 属性から導出されたガイドラインデフォルトを使う
50
+
51
+ `fixed-size` / `unsafe-non-guideline-scale` 属性は CSS の `attr()` での
52
+ 数値解釈に制約があるためこの段階では参照できず、暫定値が表示されます。
53
+ **ガイドライン外のサイズで CLS を完全に防ぐには、必ず `style="--charcoal-icon-size: Npx"` をインラインで併記してください** (React の `<Icon fixedSize>` 経由なら自動で付与されます)。
54
+
55
+ ### hydrate 後 (`:defined` 状態)
56
+
57
+ JS が読み込まれて web component が定義されると、`render()` が以下の優先順で size を計算し、
58
+ **結果を `--charcoal-icon-size` の inline style に上書きします**。
59
+
60
+ 1. `fixed-size` 属性(最優先 / ピクセル値)
61
+ 2. `unsafe-non-guideline-scale` 属性 × `name` のベースサイズ(**deprecated**)
62
+ 3. `name` プレフィックスと `scale` 属性から導出されたガイドラインデフォルト
63
+
64
+ つまりユーザーが書いた `style="--charcoal-icon-size: ..."` は **hydrate 後には属性ベースの計算結果で上書きされる** 点に注意してください。永続的に上書きしたい場合は属性で指定します。
53
65
 
54
66
  ```html
67
+ <!-- ガイドライン通り 24px -->
68
+ <pixiv-icon name="24/Add"></pixiv-icon>
69
+
70
+ <!-- ガイドライン通り 48px (24 × scale=2) -->
71
+ <pixiv-icon name="24/Add" scale="2"></pixiv-icon>
72
+
73
+ <!-- 12px に固定 (hydrate 後も 12px のまま)。SSR の CLS を避けるため style も併記 -->
55
74
  <pixiv-icon
56
75
  name="24/Add"
57
- unsafe-non-guideline-scale="0.5"
58
- style="--scale: 0.5;"
76
+ fixed-size="12"
77
+ style="--charcoal-icon-size: 12px"
78
+ ></pixiv-icon>
79
+ ```
80
+
81
+ ### `scale` 属性のプレフィックスごとの挙動
82
+
83
+ `name` プレフィックスごとに `scale` のサポート範囲が異なります。表に整理すると以下の通りです。
84
+
85
+ | プレフィックス | `scale=1` | `scale=2` | `scale=3` | 備考 |
86
+ | -------------- | --------- | --------- | --------- | --------------------------------- |
87
+ | `16/` | 16 | **16** | **16** | `scale` を無視(意図的な仕様) |
88
+ | `24/` | 24 | 48 | 72 | すべて対応 |
89
+ | `32/` | 32 | **32** | **32** | `scale` を無視(意図的な仕様) |
90
+ | `Inline/` | 16 | 32 | **16** | `scale=3` は `scale=1` と同じ扱い |
91
+
92
+ `scale` に対応していない組み合わせを書いても **CLS は発生しません**(CSS と JS 双方で同じ結果を返すように揃えてあります)が、期待通りに大きくならないことに注意してください。
93
+ 大きく表示したい場合は次節の「ガイドライン外のサイズ指定」を使ってください。
94
+
95
+ ### ガイドライン外のサイズ指定
96
+
97
+ ガイドライン外のサイズを使う場合は **`fixed-size` (vanilla HTML) / `fixedSize` (React)** を使います。
98
+ `fixed-size` は他のサイズ指定 (`scale` / `unsafe-non-guideline-scale`) よりも常に優先されます。
99
+
100
+ #### React (`<Icon fixedSize={N}>`)
101
+
102
+ ```jsx
103
+ <Icon name="24/Add" fixedSize={12} /> {/* 12px 固定 */}
104
+ <Icon name="24/Add" fixedSize={40} /> {/* 40px 固定 */}
105
+ ```
106
+
107
+ `<Icon>` は同じ値を `--charcoal-icon-size` インライン CSS 変数として自動付与するため、
108
+ **hydrate 前後とも正しいサイズで CLS は起きません**。利用者側で追加の対応は不要です。
109
+
110
+ #### vanilla HTML (`<pixiv-icon fixed-size="N">`)
111
+
112
+ CSS の `attr()` 関数は数値や `<length>` 型としての解釈をまだ十分にサポートしていないため、
113
+ **`fixed-size` 属性だけではブラウザは hydrate 前にサイズを決定できません**。
114
+ SSR 段階から CLS を防ぐには **必ずインラインスタイルで `--charcoal-icon-size` も同じ値を指定してください**。
115
+
116
+ ```html
117
+ <!-- ✅ OK: fixed-size と --charcoal-icon-size の両方を指定 -->
118
+ <pixiv-icon
119
+ name="24/Add"
120
+ fixed-size="12"
121
+ style="--charcoal-icon-size: 12px;"
59
122
  ></pixiv-icon>
123
+
124
+ <!-- ❌ NG: fixed-size だけだと CSS-only 段階で name 由来の 24px が表示され、
125
+ JS upgrade 後に 12px へジャンプする (= CLS) -->
126
+ <pixiv-icon name="24/Add" fixed-size="12"></pixiv-icon>
127
+ ```
128
+
129
+ `fixed-size` 属性は Web Component の upgrade 後に **必ず** 反映され、
130
+ `--charcoal-icon-size` インラインスタイルは upgrade 前の CSS-only 状態でサイズを確定させる役割を果たします。
131
+ 両者の値が一致していれば、SSR から hydrate 完了までを通じて同じサイズで表示されます。
132
+
133
+ サーバーサイドのテンプレートで両方を出し分けるユーティリティを用意するか、React の `<Icon fixedSize>` を経由するのが安全です。
134
+
135
+ #### 非推奨: `unsafe-non-guideline-scale`
136
+
137
+ 過去には `unsafe-non-guideline-scale` 属性でガイドライン外の倍率を指定できましたが、
138
+ これも `fixed-size` と同じ理由で CSS-only 段階ではサイズを決定できません。
139
+ **新規利用は `fixed-size` への移行を推奨します**。
140
+
141
+ 既存利用箇所では、以下のように個別にインラインスタイルでサイズを確定させる必要があります。
142
+
143
+ ```html
60
144
  <pixiv-icon
61
145
  name="24/Add"
62
146
  unsafe-non-guideline-scale="0.5"
63
- style="width: 12px; height: 12px;"
147
+ style="--charcoal-icon-size: 12px;"
64
148
  ></pixiv-icon>
65
149
  ```
@@ -0,0 +1,115 @@
1
+ export type IconSizing =
2
+ | {
3
+ scale?: 1 | 2 | 3 | '1' | '2' | '3'
4
+ /** @deprecated `fixedSize` を利用してください。 */
5
+ unsafeNonGuidelineScale?: never
6
+ fixedSize?: never
7
+ }
8
+ | {
9
+ scale?: never
10
+ /** @deprecated `fixedSize` を利用してください。 */
11
+ unsafeNonGuidelineScale: number
12
+ fixedSize?: never
13
+ }
14
+ | {
15
+ scale?: never
16
+ /** @deprecated `fixedSize` を利用してください。 */
17
+ unsafeNonGuidelineScale?: never
18
+ fixedSize: number
19
+ }
20
+
21
+ const isPositiveFinite = (value: unknown): value is number =>
22
+ typeof value === 'number' && Number.isFinite(value) && value > 0
23
+
24
+ const parseIconName = (name: string): { size: string; baseSize: number } => {
25
+ if (!name.includes('/')) {
26
+ throw new TypeError(
27
+ `"${name}" is not a valid icon name. "name" must be named like [size]/[Name].`,
28
+ )
29
+ }
30
+
31
+ const [size] = name.split('/')
32
+
33
+ if (size === 'Inline') {
34
+ return { size, baseSize: 16 }
35
+ }
36
+
37
+ const baseSize = parseInt(size, 10)
38
+ if (Number.isNaN(baseSize) || baseSize <= 0) {
39
+ throw new TypeError(
40
+ `"${name}" has invalid size prefix "${size}". Must be "Inline" or a positive number.`,
41
+ )
42
+ }
43
+
44
+ return { size, baseSize }
45
+ }
46
+
47
+ function inlineSize(scale: number): number {
48
+ switch (scale) {
49
+ case 2:
50
+ return 32
51
+ default:
52
+ return 16
53
+ }
54
+ }
55
+
56
+ function guidelineSize24(scale: number): number {
57
+ return 24 * scale
58
+ }
59
+
60
+ // fixedSize > unsafeNonGuidelineScale > scale の優先順位で生のサイズを算出する。
61
+ // 戻り値の最終 validation は呼び出し元 (calcActualSize) で行う。
62
+ const resolveSize = ({
63
+ name,
64
+ scale,
65
+ unsafeNonGuidelineScale,
66
+ fixedSize,
67
+ }: { name: string } & IconSizing): number => {
68
+ // fixedSize (px 直接指定) が最優先
69
+ if (isPositiveFinite(fixedSize)) {
70
+ return fixedSize
71
+ }
72
+ if (fixedSize !== undefined) {
73
+ throw new TypeError(
74
+ `fixedSize must be a positive finite number, got ${fixedSize}`,
75
+ )
76
+ }
77
+
78
+ const { size, baseSize } = parseIconName(name)
79
+
80
+ // unsafeNonGuidelineScale (deprecated) が次に優先
81
+ if (isPositiveFinite(unsafeNonGuidelineScale)) {
82
+ return baseSize * unsafeNonGuidelineScale
83
+ }
84
+ if (unsafeNonGuidelineScale !== undefined) {
85
+ throw new TypeError(
86
+ `unsafeNonGuidelineScale must be a positive finite number, got ${unsafeNonGuidelineScale}`,
87
+ )
88
+ }
89
+
90
+ // ガイドライン scale
91
+ const numericScale = parseInt(`${scale ?? '1'}`, 10)
92
+ switch (size) {
93
+ case 'Inline':
94
+ return inlineSize(numericScale)
95
+ case '24':
96
+ return guidelineSize24(numericScale)
97
+ default:
98
+ return baseSize
99
+ }
100
+ }
101
+
102
+ export const calcActualSize = (
103
+ params: { name: string } & IconSizing,
104
+ ): number => {
105
+ const actualSize = resolveSize(params)
106
+
107
+ // 全 return パスの結果が正の有限数であることを Single Source of Truth として保証する
108
+ if (!isPositiveFinite(actualSize)) {
109
+ throw new TypeError(
110
+ `icon size must be a positive finite number, got ${actualSize}`,
111
+ )
112
+ }
113
+
114
+ return actualSize
115
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { PixivIcon } from './PixivIcon'
2
2
  import { __SERVER__ } from './ssr'
3
3
  export { PixivIcon, type KnownIconType, type Props } from './PixivIcon'
4
+ export { calcActualSize, type IconSizing } from './calcActualSize'
4
5
  export { KNOWN_ICON_FILES } from './charcoalIconFiles'
5
6
  export { PixivIconLoadError } from './loaders/PixivIconLoadError'
6
7
 
@@ -0,0 +1,28 @@
1
+ import * as React from 'react'
2
+ import type { SVGProps } from 'react'
3
+ import { Ref, forwardRef } from 'react'
4
+ const SvgDescription = (
5
+ props: SVGProps<SVGSVGElement>,
6
+ ref: Ref<SVGSVGElement>,
7
+ ) => (
8
+ <svg
9
+ width={16}
10
+ height={16}
11
+ viewBox="0 0 16 16"
12
+ fill="none"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ ref={ref}
15
+ {...props}
16
+ >
17
+ <path
18
+ fillRule="evenodd"
19
+ clipRule="evenodd"
20
+ d="M12.707 5.207a1 1 0 01.293.707V12a2 2 0 01-2 2H5a2 2 0 01-2-2V4a2 2 0 012-2h4.086a1 1 0 01.707.293l2.914 2.914zM6 10a.5.5 0 000 1h4a.5.5 0 000-1H6zm0-2a.5.5 0 000 1h4a.5.5 0 000-1H6zm2.5-2.5a1 1 0 001 1H12L8.5 3v2.5z"
21
+ fill="currentColor"
22
+ />
23
+ </svg>
24
+ )
25
+ export const IconDescription16: ReturnType<
26
+ typeof React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>
27
+ > = forwardRef(SvgDescription)
28
+ export default IconDescription16
@@ -11,6 +11,7 @@ import { IconBookmarkOff16 } from './16/BookmarkOff'
11
11
  import { IconBookmarkOn16 } from './16/BookmarkOn'
12
12
  import { IconCheck16 } from './16/Check'
13
13
  import { IconComment16 } from './16/Comment'
14
+ import { IconDescription16 } from './16/Description'
14
15
  import { IconDot16 } from './16/Dot'
15
16
  import { IconDown16 } from './16/Down'
16
17
  import { IconError16 } from './16/Error'
@@ -632,6 +633,11 @@ export default {
632
633
  <code>&lt;IconDelete32 /&gt;</code>
633
634
  </div>
634
635
 
636
+ <div>
637
+ <IconDescription16 />
638
+ <code>&lt;IconDescription16 /&gt;</code>
639
+ </div>
640
+
635
641
  <div>
636
642
  <IconDescription24 />
637
643
  <code>&lt;IconDescription24 /&gt;</code>
@@ -8,6 +8,7 @@ export { IconBookmarkOff16 } from './16/BookmarkOff'
8
8
  export { IconBookmarkOn16 } from './16/BookmarkOn'
9
9
  export { IconCheck16 } from './16/Check'
10
10
  export { IconComment16 } from './16/Comment'
11
+ export { IconDescription16 } from './16/Description'
11
12
  export { IconDot16 } from './16/Dot'
12
13
  export { IconDown16 } from './16/Down'
13
14
  export { IconError16 } from './16/Error'