@ynetlabo/ui-core 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuuki.U
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @ynetlabo/ui-core
2
+
3
+ [ui-labo](https://github.com/yuuint/ui-labo) のプレーン Web 実装。
4
+ 素の Custom Elements で書いており、ランタイム依存はありません。
5
+
6
+ **[デモとドキュメント](https://yuuint.github.io/ui-labo/)**
7
+
8
+ ```sh
9
+ npm install @ynetlabo/ui-core
10
+ ```
11
+
12
+ ```html
13
+ <link rel="stylesheet" href="node_modules/@ynetlabo/ui-tokens/dist/tokens.css">
14
+ <script type="module">
15
+ import "@ynetlabo/ui-core";
16
+ </script>
17
+
18
+ <yn-prefecture-picker name="pref" code-format="jis"></yn-prefecture-picker>
19
+ ```
20
+
21
+ `import` した時点でカスタム要素が登録されます。色・寸法・モーションは
22
+ [`@ynetlabo/ui-tokens`](https://www.npmjs.com/package/@ynetlabo/ui-tokens) の
23
+ CSS カスタムプロパティ経由なので、読み込むだけでテーマが効きます。
24
+
25
+ ## コンポーネント
26
+
27
+ | 要素 | 仕様 |
28
+ |---|---|
29
+ | `<yn-prefecture-picker>` | [都道府県 Picker](https://yuuint.github.io/ui-labo/prefecture-picker.html) |
30
+
31
+ ## フレームワークから使う
32
+
33
+ Vue では、カスタム要素と誤認させない設定と、値のプロパティ束縛が必要です。
34
+
35
+ ```ts
36
+ // nuxt.config.ts / vite.config.ts
37
+ vue: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("yn-") } }
38
+ ```
39
+
40
+ ```vue
41
+ <yn-prefecture-picker :value.prop="pref" @change="pref = $event.detail.value" />
42
+ ```
43
+
44
+ MIT © ynetlabo
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@ynetlabo/ui-core",
3
+ "version": "0.1.0",
4
+ "description": "ui-labo のプレーン Web 実装。素の Custom Elements で書き、第三者ライブラリを使わない",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./prefecture-picker": "./src/components/prefecture-picker/yn-prefecture-picker.js"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "ynetlabo": {
16
+ "externalDeps": "workspace-only"
17
+ },
18
+ "dependencies": {
19
+ "@ynetlabo/ui-data": "^0.1.0",
20
+ "@ynetlabo/ui-tokens": "^0.1.0"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test test/*.test.mjs"
24
+ },
25
+ "homepage": "https://yuuint.github.io/ui-labo/",
26
+ "bugs": {
27
+ "url": "https://github.com/yuuint/ui-labo/issues"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/yuuint/ui-labo.git",
35
+ "directory": "packages/core"
36
+ },
37
+ "keywords": [
38
+ "web-components",
39
+ "custom-elements",
40
+ "design-system",
41
+ "japan",
42
+ "prefecture",
43
+ "ui-labo",
44
+ "ynetlabo"
45
+ ],
46
+ "sideEffects": [
47
+ "./src/index.js",
48
+ "./src/components/**/*.js"
49
+ ]
50
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 最小の基底クラス。属性とプロパティの同期、まとめ描画だけを持つ。
3
+ *
4
+ * Lit を使わない代わりに書くのはここだけに閉じる(ADR plain-web-custom-elements)。
5
+ * 300 行を超えたら Lit の再実装に近づいている兆候なので、その時点で方針を見直す。
6
+ */
7
+ export class YnElement extends HTMLElement {
8
+ /** @type {Record<string, {attr?: string, type?: 'string'|'number'|'boolean'|'json', value?: any, reflect?: boolean}>} */
9
+ static props = {};
10
+
11
+ static get observedAttributes() {
12
+ return Object.entries(this.props).map(([k, d]) => d.attr ?? toAttr(k));
13
+ }
14
+
15
+ #state = {};
16
+ #frame = 0;
17
+ #ready = false;
18
+ #reflecting = false;
19
+
20
+ constructor() {
21
+ super();
22
+ const props = this.constructor.props;
23
+ for (const [key, def] of Object.entries(props)) {
24
+ // 定義前の要素にフレームワークが値を入れていることがある。
25
+ // そのまま defineProperty すると消えるので、いったん退避して後から入れ直す
26
+ const pending = Object.hasOwn(this, key) ? this[key] : undefined;
27
+ delete this[key];
28
+
29
+ this.#state[key] = def.value;
30
+ Object.defineProperty(this, key, {
31
+ get: () => this.#state[key],
32
+ set: (v) => {
33
+ if (this.#state[key] === v) return; // 変わっていないなら何もしない
34
+ this.#state[key] = v;
35
+ // 属性・プロパティのどちらから来ても、ここ 1 か所を通る。
36
+ // フレームワークはプロパティで値を渡すため、属性だけを見ていると取りこぼす
37
+ this.#reflect(key, def, v);
38
+ this.propChanged?.(key, v);
39
+ this.requestUpdate();
40
+ },
41
+ enumerable: true,
42
+ });
43
+
44
+ if (pending !== undefined) this[key] = pending;
45
+ }
46
+ }
47
+
48
+ connectedCallback() {
49
+ if (!this.#ready) { this.#ready = true; this.setup?.(); }
50
+ // 初回は同期で描く。次のフレームに任せると、非表示タブや
51
+ // バックグラウンドでは requestAnimationFrame が発火せず一度も描画されない
52
+ this.updateNow();
53
+ }
54
+
55
+ attributeChangedCallback(attr, _old, value) {
56
+ if (this.#reflecting) return; // 自分で書き戻した分は読み返さない
57
+ const entry = Object.entries(this.constructor.props)
58
+ .find(([k, d]) => (d.attr ?? toAttr(k)) === attr);
59
+ if (!entry) return;
60
+ const [key, def] = entry;
61
+ this[key] = parse(value, def.type ?? "string", def.value);
62
+ }
63
+
64
+ /**
65
+ * プロパティの変更を属性へ書き戻す。
66
+ * CSS は :host([mode="inline"]) のように属性を見るため、
67
+ * Vue のようにプロパティで値を渡すフレームワークでは属性がないと何も効かない。
68
+ * 属性で表せない値(配列・オブジェクト・json 型)は対象外。
69
+ */
70
+ #reflect(key, def, v) {
71
+ const type = def.type ?? "string";
72
+ if (def.reflect === false || type === "json") return;
73
+ if (v !== null && typeof v === "object") return;
74
+ const attr = def.attr ?? toAttr(key);
75
+ this.#reflecting = true;
76
+ try {
77
+ if (type === "boolean") this.toggleAttribute(attr, !!v);
78
+ else if (v === null || v === undefined) this.removeAttribute(attr);
79
+ else this.setAttribute(attr, String(v));
80
+ } finally {
81
+ this.#reflecting = false;
82
+ }
83
+ }
84
+
85
+ /** 同じフレーム内の複数変更を 1 回の描画にまとめる(初回描画のあとだけ) */
86
+ requestUpdate() {
87
+ if (!this.isConnected || !this.#ready || this.#frame) return;
88
+ this.#frame = requestAnimationFrame(() => { this.#frame = 0; this.update?.(); });
89
+ }
90
+
91
+ /** 直ちに描画する(テストや、遷移の開始位置を確定させたいとき) */
92
+ updateNow() {
93
+ if (this.#frame) { cancelAnimationFrame(this.#frame); this.#frame = 0; }
94
+ this.update?.();
95
+ }
96
+
97
+ emit(type, detail) {
98
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
99
+ }
100
+ }
101
+
102
+ const toAttr = (key) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
103
+
104
+ function parse(value, type, fallback) {
105
+ if (value === null) return type === "boolean" ? false : fallback;
106
+ switch (type) {
107
+ case "boolean": return value !== "false";
108
+ case "number": { const n = Number(value); return Number.isNaN(n) ? fallback : n; }
109
+ case "json": try { return JSON.parse(value); } catch { return fallback; }
110
+ default: return value;
111
+ }
112
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Shadow DOM 内のスタイル。値は @ynetlabo/ui-tokens の CSS カスタムプロパティを参照する。
3
+ * カスタムプロパティは shadow 境界を越えて継承されるので、テーマの切り替えはそのまま効く。
4
+ * tokens.css を読み込んでいない場合でも壊れないよう、すべてにフォールバックを置く。
5
+ */
6
+ export const css = /* css */ `
7
+ :host {
8
+ display: block;
9
+ position: relative;
10
+ --_ink: var(--color-ink, #14171d);
11
+ --_ink2: var(--color-ink2, #4c5666);
12
+ --_ink3: var(--color-ink3, #818c9d);
13
+ --_surface: var(--prefecture-picker-trigger-bg, #fff);
14
+ --_line: var(--color-line, #dce2ea);
15
+ --_line2: var(--prefecture-picker-trigger-border, #c5cdd9);
16
+ --_accent: var(--prefecture-picker-trigger-border-hover, #1b4d89);
17
+ --_wash: var(--color-accent-wash, #e6eef8);
18
+ --_sunken: var(--prefecture-picker-option-bg-hover, #edf1f6);
19
+ --_danger: var(--color-danger, #a8443a);
20
+ --_sea: var(--prefecture-picker-map-sea, #e7eef5);
21
+ --_landline: var(--prefecture-picker-map-land-line, #fff);
22
+ --_outline: var(--prefecture-picker-map-outline, #14171d);
23
+ --_outlineHover: var(--prefecture-picker-map-outline-hover, #4c5666);
24
+ --_h: var(--prefecture-picker-trigger-height, 38px);
25
+ --_r: var(--prefecture-picker-trigger-radius, 6px);
26
+ --_panelR: var(--prefecture-picker-popover-radius, 14px);
27
+ --_sheetR: var(--prefecture-picker-sheet-radius, 18px);
28
+ --_touch: var(--prefecture-picker-sheet-min-target, 44px);
29
+ --_dur: var(--prefecture-picker-popover-duration, 170ms);
30
+ --_sheetDur: var(--prefecture-picker-sheet-duration, 300ms);
31
+ --_zoomDur: var(--prefecture-picker-map-zoom-duration, 520ms);
32
+ --_dilate: var(--prefecture-picker-map-fill-dilate, 4.5px);
33
+ --_olW: var(--prefecture-picker-map-outline-width, 8.5px);
34
+ --_olHoverW: var(--prefecture-picker-map-outline-hover-width, 3.4px);
35
+ --_hairline: var(--prefecture-picker-map-land-line-width, 1px);
36
+ --_mapMax: var(--prefecture-picker-map-max-height, min(56vh, 500px));
37
+ --_listMax: var(--prefecture-picker-list-max-height, 236px);
38
+ --_popMin: var(--prefecture-picker-popover-min-width, 272px);
39
+ --_popMax: var(--prefecture-picker-popover-max-width, 420px);
40
+ font: inherit;
41
+ color: var(--_ink);
42
+ }
43
+ :host([hidden]) { display: none }
44
+ * { box-sizing: border-box }
45
+ button, input { font: inherit; color: inherit; touch-action: manipulation }
46
+ :focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; border-radius: 5px }
47
+
48
+ /* ---- トリガ ---- */
49
+ .trigger {
50
+ display: flex; align-items: center; gap: 8px; width: 100%;
51
+ height: var(--_h); padding: 0 11px;
52
+ border: 1px solid var(--_line2); border-radius: var(--_r);
53
+ background: var(--_surface); cursor: pointer; text-align: left;
54
+ transition: border-color .12s, box-shadow .12s;
55
+ }
56
+ .trigger:hover:not(:disabled) { border-color: var(--_accent) }
57
+ .trigger[aria-expanded="true"] { border-color: var(--_accent); box-shadow: 0 0 0 3px var(--_wash) }
58
+ .trigger:disabled { background: var(--_sunken); color: var(--_ink3); cursor: default }
59
+ :host([invalid]) .trigger, .trigger[aria-invalid="true"] { border-color: var(--_danger) }
60
+ .ph { color: var(--_ink3) }
61
+ .more { color: var(--_ink3); font-size: .86em }
62
+ .emit { margin-left: auto; font-size: .78em; color: var(--_ink3);
63
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace }
64
+ .caret { color: var(--_ink3); font-size: .7em }
65
+
66
+ /* ---- ポップオーバー ---- */
67
+ .scrim { display: none }
68
+ .panel {
69
+ position: absolute; z-index: 40; top: calc(100% + 5px); left: 0;
70
+ width: 100%; min-width: var(--_popMin);
71
+ max-width: min(var(--_popMax), calc(100vw - 24px));
72
+ background: var(--_surface); border: 1px solid var(--_line2);
73
+ border-radius: var(--_panelR); box-shadow: 0 4px 6px -2px rgba(20,23,29,.06), 0 14px 34px -12px rgba(20,23,29,.28);
74
+ overflow: hidden;
75
+ --_cy: -6px; --_cs: .985; --_co: 0; --_d: var(--_dur);
76
+ --_ease: cubic-bezier(.22,.7,.3,1);
77
+ transform-origin: top center;
78
+ opacity: var(--_co); transform: translateY(var(--_cy)) scale(var(--_cs)); visibility: hidden;
79
+ transition: opacity var(--_d) var(--_ease), transform var(--_d) var(--_ease),
80
+ height var(--_d) var(--_ease), visibility 0s linear var(--_d);
81
+ }
82
+ .panel.open { opacity: 1; transform: none; visibility: visible;
83
+ transition: opacity var(--_d) var(--_ease), transform var(--_d) var(--_ease),
84
+ height var(--_d) var(--_ease), visibility 0s }
85
+ :host([transition="fade"]) .panel { --_cy: 0; --_cs: 1 }
86
+ :host([transition="slide"]) .panel { --_cy: 10px; --_cs: 1; --_d: 240ms }
87
+ :host([transition="none"]) .panel, :host([transition="none"]) .scrim { --_d: 0s }
88
+
89
+ /* ---- 常時表示(ダイアログにしない) ---- */
90
+ :host([mode="inline"]) .panel {
91
+ position: static; width: 100%; min-width: 0; max-width: none;
92
+ box-shadow: none; visibility: visible; opacity: 1; transform: none;
93
+ transition: height var(--_dur) var(--_ease);
94
+ }
95
+ :host([mode="inline"]) .scrim { display: none }
96
+ :host([mode="inline"]) .trigger[hidden] { display: none }
97
+
98
+ .head { display: flex; align-items: center; gap: 7px; padding: 8px 9px; border-bottom: 1px solid var(--_line) }
99
+ .back { display: inline-flex; align-items: center; height: 28px; padding: 0 9px;
100
+ border: 1px solid var(--_line2); background: var(--_surface); border-radius: 6px;
101
+ cursor: pointer; font-size: 12px; white-space: nowrap }
102
+ .back:hover { border-color: var(--_accent); color: var(--_accent) }
103
+ .here { font-size: 12px; font-weight: 700; white-space: nowrap; overflow: hidden;
104
+ text-overflow: ellipsis; min-width: 0; padding: 0 2px }
105
+ .modes { display: inline-flex; gap: 2px; margin-left: auto; background: var(--_sunken);
106
+ padding: 2px; border-radius: 8px; flex: 0 0 auto }
107
+ .modes button { height: 26px; padding: 0 10px; border: 0; background: transparent;
108
+ border-radius: 6px; cursor: pointer; font-size: 11.5px; color: var(--_ink2) }
109
+ .modes button[aria-pressed="true"] { background: var(--_surface); color: var(--_ink);
110
+ font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.12) }
111
+ .modes button:disabled { opacity: .4; cursor: default }
112
+ .close { display: none }
113
+ .search-row { padding: 8px 9px 0 }
114
+ .search { width: 100%; height: 30px; padding: 0 9px; border: 1px solid var(--_line2);
115
+ border-radius: 6px; background: var(--_surface); font-size: 12.5px }
116
+
117
+ /* ---- 地図 ---- */
118
+ .mapbox { background: var(--_sea); display: flex; justify-content: center; overflow: hidden }
119
+ svg { display: block; width: 100%; height: auto; max-height: var(--_mapMax) }
120
+ .zoom { transform-box: view-box; transform-origin: 0 0;
121
+ transition: transform var(--_zoomDur) cubic-bezier(.3,.8,.25,1) }
122
+ .pref { fill: var(--tc); fill-opacity: .5; stroke: var(--_landline); stroke-width: var(--_hairline);
123
+ stroke-linejoin: round; cursor: pointer; transition: fill-opacity .16s, opacity .18s }
124
+ .pref.dim { opacity: .2; pointer-events: none }
125
+ .pref.hot { fill-opacity: .94 }
126
+ .pref.sel { fill-opacity: 1 }
127
+ /* エリアは 1 面として描く。半透明にすると重なった帯だけ濃くなるので不透明色で薄さを作る */
128
+ .area-fill { --pale: color-mix(in oklab, var(--tc) 48%, var(--_sea));
129
+ fill: var(--pale); stroke: var(--pale); stroke-width: var(--_dilate);
130
+ stroke-linejoin: round; stroke-linecap: round; cursor: pointer;
131
+ transition: fill .16s, stroke .16s }
132
+ .area-fill.hot { --pale: color-mix(in oklab, var(--tc) 90%, var(--_sea)) }
133
+ .area-fill.sel { --pale: var(--tc) }
134
+ /* 塗りの下に敷く縁取り。上の塗りで覆われ、外周だけが残る */
135
+ .area-edge { fill: var(--_landline); stroke: var(--_landline); stroke-width: 9;
136
+ stroke-linejoin: round; stroke-linecap: round; pointer-events: none }
137
+ .area-edge.sel { --ol: color-mix(in oklab, var(--_outline) 74%, var(--_sea));
138
+ fill: var(--ol); stroke: var(--ol); stroke-width: var(--_olW) }
139
+ .ring { stroke-linejoin: round; stroke-linecap: round; pointer-events: none }
140
+ .ring.sel { fill: var(--_outline); stroke: var(--_outline); stroke-width: 5 }
141
+ .ring.hot { fill: var(--_outlineHover); stroke: var(--_outlineHover);
142
+ stroke-width: var(--_olHoverW); opacity: .8 }
143
+ .label { font-weight: 700; fill: #fff; paint-order: stroke; stroke: rgba(0,0,0,.5);
144
+ stroke-width: 5; stroke-linejoin: round; text-anchor: middle;
145
+ dominant-baseline: central; pointer-events: none }
146
+ .check { pointer-events: none }
147
+ .check circle { fill: var(--_outline); stroke: var(--_surface) }
148
+ .check path { fill: none; stroke: var(--_surface); stroke-linecap: round; stroke-linejoin: round }
149
+ .inset { fill: none; stroke: var(--_line2); stroke-dasharray: 7 6; stroke-width: 2; pointer-events: none }
150
+ .inset-hit { fill: transparent; cursor: pointer }
151
+ .inset-hit.hot { fill: var(--_ink); fill-opacity: .07 }
152
+
153
+ /* ---- 一覧 ---- */
154
+ .list { max-height: var(--_listMax); overflow: auto; padding: 6px }
155
+ .group-head { font-size: 10.5px; letter-spacing: .08em; color: var(--_ink3); padding: 6px 8px 3px }
156
+ .opt { display: flex; align-items: baseline; gap: 8px; width: 100%; padding: 6px 8px;
157
+ border: 0; background: transparent; border-radius: 5px; cursor: pointer; text-align: left }
158
+ .opt:hover { background: var(--_sunken); box-shadow: inset 0 0 0 1.5px var(--_ink) }
159
+ .opt[aria-selected="true"] { background: var(--prefecture-picker-option-bg-selected, #1b4d89);
160
+ color: var(--prefecture-picker-option-text-selected, #fff); font-weight: 700 }
161
+ .opt .code { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px;
162
+ color: var(--_ink3); min-width: 20px }
163
+ .opt[aria-selected="true"] .code { color: rgba(255,255,255,.75) }
164
+ .opt[aria-selected="true"] .code::before { content: "✓"; margin-right: 3px }
165
+ .opt .kana { font-size: 10.5px; color: var(--_ink3); margin-left: auto }
166
+ .opt[aria-selected="true"] .kana { color: rgba(255,255,255,.75) }
167
+ .empty { padding: 22px 10px; text-align: center; color: var(--_ink3); font-size: 12.5px }
168
+
169
+ /* ---- 複数選択のフッタ ---- */
170
+ .foot { display: flex; align-items: center; justify-content: flex-end; gap: 7px; padding: 8px 9px;
171
+ border-top: 1px solid var(--_line); background: var(--_sunken) }
172
+ .count { font-size: 11.5px; color: var(--_ink2); margin-right: auto;
173
+ font-variant-numeric: tabular-nums }
174
+ .fbtn { height: 28px; padding: 0 11px; border: 1px solid var(--_line2); border-radius: 6px;
175
+ background: var(--_surface); cursor: pointer; font-size: 12px }
176
+ .fbtn:disabled { opacity: .45; cursor: default }
177
+ .fbtn.primary { background: var(--_accent); border-color: var(--_accent); color: #fff; font-weight: 700 }
178
+
179
+ /* ---- 狭い画面: 下からのシート ---- */
180
+ @media (max-width: 599px) {
181
+ .scrim { display: block; position: fixed; inset: 0; background: rgba(10,14,20,.42); z-index: 39;
182
+ opacity: 0; visibility: hidden;
183
+ transition: opacity var(--_sheetDur) ease, visibility 0s linear var(--_sheetDur) }
184
+ .scrim.open { opacity: 1; visibility: visible; transition: opacity var(--_sheetDur) ease, visibility 0s }
185
+ :host([mode="inline"]) .panel { position: static; border-radius: var(--_panelR);
186
+ border-bottom: 1px solid var(--_line2); max-height: none; padding-bottom: 0 }
187
+ :host([mode="inline"]) .head { position: static; padding: 10px 10px 9px }
188
+ :host([mode="inline"]) .head::before { content: none }
189
+ :host([mode="inline"]) .search-row { position: static; padding: 8px 10px 0 }
190
+ .panel { position: fixed; left: 0; right: 0; bottom: var(--_kb, 0px); top: auto;
191
+ width: 100%; min-width: 0; max-width: none;
192
+ border-radius: var(--_sheetR) var(--_sheetR) 0 0; border-bottom: 0;
193
+ max-height: 88vh; overflow: auto; overscroll-behavior: contain;
194
+ padding-bottom: env(safe-area-inset-bottom);
195
+ --_cy: 100%; --_cs: 1; --_co: 1; --_d: var(--_sheetDur);
196
+ --_ease: cubic-bezier(.32,.72,0,1); transform-origin: bottom center }
197
+ :host([transition="fade"]) .panel { --_cy: 0; --_co: 0 }
198
+ .head { position: sticky; top: 0; z-index: 2; background: var(--_surface); padding: 14px 12px 10px }
199
+ .head::before { content: ""; position: absolute; top: 5px; left: 50%; transform: translateX(-50%);
200
+ width: 36px; height: 4px; border-radius: 2px; background: var(--_line2) }
201
+ .search-row { position: sticky; top: 66px; z-index: 2; background: var(--_surface); padding: 0 12px 10px }
202
+ .close { display: inline-flex; align-items: center; justify-content: center;
203
+ width: 40px; height: 40px; flex: 0 0 auto; border: 1px solid var(--_line2);
204
+ border-radius: 10px; background: var(--_surface); cursor: pointer; font-size: 16px }
205
+ .search, .back { height: var(--_touch) }
206
+ .modes { padding: 3px }
207
+ .modes button { height: 34px; padding: 0 15px; font-size: 13px }
208
+ .fbtn { height: var(--_touch); padding: 0 16px; font-size: 13.5px }
209
+ .opt { padding: 11px 10px; font-size: 14px }
210
+ .list { max-height: none }
211
+ svg { max-height: 44vh }
212
+ }
213
+
214
+ @media (prefers-reduced-motion: reduce) {
215
+ .panel, .scrim, .zoom, .pref, .area-fill { transition: none !important }
216
+ }
217
+ `;
@@ -0,0 +1,518 @@
1
+ /**
2
+ * <yn-prefecture-picker>
3
+ * 仕様は spec `prefecture-picker`。第三者ライブラリを使わない素の Custom Element。
4
+ */
5
+ import { YnElement } from "../../base/element.js";
6
+ import { PREFECTURES } from "@ynetlabo/ui-data";
7
+ import { VIEW_BOX, PATHS } from "@ynetlabo/ui-data/outline";
8
+ import { normalize, match } from "@ynetlabo/ui-data/normalize";
9
+ import { grouping, members } from "../../lib/grouping.js";
10
+ import { formatPrefecture, formatArea, displayOf, toKey } from "../../lib/value.js";
11
+ import { css } from "./styles.js";
12
+
13
+ /** エリアの中心。所属県の面積で重み付けする(大きい県に引っぱられるのが自然) */
14
+ function areaCenter(codes) {
15
+ let w = 0, x = 0, y = 0;
16
+ for (const c of codes) {
17
+ const o = PATHS[c]; if (!o) continue;
18
+ const a = o.a ?? 1;
19
+ x += o.c[0] * a; y += o.c[1] * a; w += a;
20
+ }
21
+ return w ? [x / w, y / w] : null;
22
+ }
23
+
24
+ /** "170ms" / "0.3s" / "0s" を数値へ */
25
+ function durationMs(v) {
26
+ const t = String(v).trim();
27
+ if (!t) return 0;
28
+ const n = parseFloat(t);
29
+ if (!Number.isFinite(n)) return 0;
30
+ return t.endsWith("ms") ? n : n * 1000;
31
+ }
32
+
33
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
34
+ const sheet = new CSSStyleSheet();
35
+ sheet.replaceSync(css);
36
+
37
+ export class YnPrefecturePicker extends YnElement {
38
+ static formAssociated = true;
39
+ static props = {
40
+ value: { value: null, reflect: false },
41
+ selectionLevel: { value: "prefecture" },
42
+ multiple: { type: "boolean", value: false },
43
+ codeFormat: { value: "jis" },
44
+ grouping: { value: "region9" },
45
+ layout: { value: "map" },
46
+ display: { value: "name" },
47
+ searchable: { type: "boolean", value: true },
48
+ placeholder: { value: "選択してください" },
49
+ disabled: { type: "boolean", value: false },
50
+ required: { type: "boolean", value: false },
51
+ invalid: { type: "boolean", value: false },
52
+ name: { value: "" },
53
+ transition: { value: "auto" },
54
+ mode: { value: "dropdown" },
55
+ footer: { value: "auto" },
56
+ items: { type: "json", value: null },
57
+ };
58
+
59
+ #sel = []; // 内部キー(県コード / エリアキー)の配列
60
+ #openState = false;
61
+ #step = "area";
62
+ #area = null;
63
+ #query = "";
64
+ #lastT = { s: 1, x: 0, y: 0 };
65
+ #internals;
66
+
67
+ constructor() {
68
+ super();
69
+ this.#internals = this.attachInternals?.();
70
+ this.attachShadow({ mode: "open" });
71
+ this.shadowRoot.adoptedStyleSheets = [sheet];
72
+ }
73
+
74
+ // --- 派生 ---
75
+ /** 常時表示(inline)では開閉の概念を持たない */
76
+ get #inline() { return this.mode === "inline"; }
77
+ get #open() { return this.#inline || this.#openState; }
78
+ set #open(v) { this.#openState = v; }
79
+
80
+ get #prefs() { return this.items ?? PREFECTURES; }
81
+ get #isArea() { return this.selectionLevel === "area"; }
82
+ /**
83
+ * フッタに出す部品。"auto" は mode と multiple から決める。
84
+ * 埋め込み先が自前のクリア/完了を持つ場合は "none" にして丸ごと消す。
85
+ */
86
+ get #footParts() {
87
+ const raw = String(this.footer ?? "auto").trim();
88
+ if (raw === "none") return [];
89
+ if (raw === "auto") {
90
+ if (!this.multiple) return [];
91
+ // 常時表示では閉じるものが無いので「完了」を出さない
92
+ return this.#inline ? ["count", "clear"] : ["count", "clear", "done"];
93
+ }
94
+ return raw.split(/\s+/).filter((k) => k === "count" || k === "clear" || k === "done");
95
+ }
96
+ get #group() { return grouping(this.grouping); }
97
+ get #members() { const g = this.#group; return g ? members(g, this.#prefs) : {}; }
98
+ #byCode(c) { return this.#prefs.find((p) => p.code === c); }
99
+
100
+ /** 検索中と、地図を持たない区切りでは一覧として描く */
101
+ #effectiveLayout() {
102
+ if (this.layout === "list") return "list";
103
+ const g = this.#group;
104
+ if (!g || !g.geo) return "list";
105
+ if (!this.#isArea && this.searchable && this.#query.trim()) return "list";
106
+ return "map";
107
+ }
108
+
109
+ /** エリア選択モードでは、選択したエリアに属する県コードを返す */
110
+ #selectedCodes() {
111
+ if (!this.#isArea) return this.#sel;
112
+ const m = this.#members;
113
+ return this.#sel.flatMap((k) => m[k] ?? []);
114
+ }
115
+
116
+ #emitValue() {
117
+ const g = this.#group, m = this.#members;
118
+ const one = (k) => this.#isArea
119
+ ? formatArea(k, { labels: g.labels, memberCodes: m[k] ?? [], byCode: (c) => this.#byCode(c), codeFormat: this.codeFormat })
120
+ : formatPrefecture(this.#byCode(k), this.codeFormat);
121
+ return this.multiple ? this.#sel.map(one) : (this.#sel.length ? one(this.#sel[0]) : null);
122
+ }
123
+
124
+ #labelOf(k) {
125
+ return this.#isArea ? this.#group.labels[k] : displayOf(this.#byCode(k), this.display);
126
+ }
127
+
128
+ // --- ライフサイクル ---
129
+ setup() {
130
+ this.addEventListener("keydown", this.#onKeydown);
131
+ document.addEventListener("click", this.#onOutside);
132
+ if (window.visualViewport) {
133
+ this.#fitKeyboard = () => {
134
+ const gap = window.innerHeight - (visualViewport.height + visualViewport.offsetTop);
135
+ this.shadowRoot.querySelector(".panel")?.style.setProperty("--_kb", (gap > 60 ? gap : 0) + "px");
136
+ };
137
+ visualViewport.addEventListener("resize", this.#fitKeyboard);
138
+ }
139
+ this.#syncFromValue();
140
+ }
141
+ disconnectedCallback() {
142
+ document.removeEventListener("click", this.#onOutside);
143
+ if (this.#fitKeyboard) visualViewport.removeEventListener("resize", this.#fitKeyboard);
144
+ }
145
+ #fitKeyboard = null;
146
+ #onOutside = (e) => {
147
+ if (this.#inline) return; // 常時表示では閉じない
148
+ if (this.#open && !e.composedPath().includes(this)) this.#close();
149
+ };
150
+
151
+ /** value 属性・プロパティから内部キーへ。不正な値は未選択として扱う */
152
+ #syncFromValue() {
153
+ const raw = this.value;
154
+ const list = raw == null ? [] : Array.isArray(raw) ? raw : [raw];
155
+ const g = this.#group, m = this.#members;
156
+ this.#sel = list
157
+ .map((v) => toKey(v, { level: this.selectionLevel, prefectures: this.#prefs, grouping: g, memberMap: m }))
158
+ .filter(Boolean);
159
+ if (!this.multiple) this.#sel = this.#sel.slice(0, 1);
160
+ }
161
+
162
+ /**
163
+ * 属性・プロパティのどちらから変わってもここを通る。
164
+ * フレームワークは value をプロパティで渡すため、属性だけを見ていると取りこぼす。
165
+ */
166
+ propChanged(key) {
167
+ if (this.#internalWrite) return; // 自分で書き戻した分は無視する
168
+ if (key === "value") return this.#syncFromValue();
169
+ if (key === "selectionLevel") { this.#sel = []; this.#syncValue(); return; }
170
+ if (key === "grouping") { if (this.#isArea) this.#sel = []; this.#syncValue(); return; }
171
+ if (key === "codeFormat" || key === "multiple") {
172
+ if (!this.multiple) this.#sel = this.#sel.slice(0, 1);
173
+ this.#syncValue();
174
+ }
175
+ }
176
+
177
+ #internalWrite = false;
178
+ /** 選択から value を作り直す。設定変更による追随なので change は発火しない */
179
+ #syncValue() {
180
+ const next = this.#emitValue();
181
+ if (JSON.stringify(next) === JSON.stringify(this.value)) return;
182
+ this.#internalWrite = true;
183
+ try { this.value = next; } finally { this.#internalWrite = false; }
184
+ }
185
+
186
+ // --- 選択 ---
187
+ #toggle(key) {
188
+ const before = JSON.stringify(this.#sel);
189
+ if (this.multiple) {
190
+ const i = this.#sel.indexOf(key);
191
+ i < 0 ? this.#sel.push(key) : this.#sel.splice(i, 1);
192
+ } else {
193
+ if (this.#sel[0] === key) return; // 同じ項目の再選択では発火しない
194
+ this.#sel = [key];
195
+ }
196
+ if (JSON.stringify(this.#sel) === before) return;
197
+ this.#commit();
198
+ if (!this.multiple) this.#close(); else this.updateNow();
199
+ }
200
+ #commit() {
201
+ const v = this.#emitValue();
202
+ this.#internalWrite = true;
203
+ try { this.value = v; } finally { this.#internalWrite = false; }
204
+ this.#setFormValue();
205
+ this.emit("change", { value: v, keys: [...this.#sel] });
206
+ }
207
+ #setFormValue() {
208
+ if (!this.#internals || !this.name) return;
209
+ if (this.multiple) {
210
+ const fd = new FormData();
211
+ for (const v of this.#emitValue()) fd.append(this.name, String(v));
212
+ this.#internals.setFormValue(fd);
213
+ } else {
214
+ const v = this.#emitValue();
215
+ this.#internals.setFormValue(v == null ? null : String(v));
216
+ }
217
+ this.#internals.setValidity(
218
+ this.required && !this.#sel.length ? { valueMissing: true } : {},
219
+ "都道府県を選択してください",
220
+ this.shadowRoot.querySelector(".trigger") ?? undefined,
221
+ );
222
+ }
223
+
224
+ #openPanel() { if (this.disabled) return; this.#open = true; this.#step = "area"; this.#area = null; this.#query = ""; this.updateNow(); }
225
+ #close() { if (this.#inline) return; this.#open = false; this.updateNow(); }
226
+ #pickArea(key) {
227
+ if (this.#isArea) return this.#toggle(key);
228
+ const ids = this.#members[key] ?? [];
229
+ if (ids.length === 1) return this.#toggle(ids[0]);
230
+ this.#area = key; this.#step = "pref"; this.updateNow();
231
+ }
232
+ #back() { this.#step = "area"; this.#area = null; this.updateNow(); }
233
+
234
+ #onKeydown = (e) => {
235
+ if (!this.#open || e.key !== "Escape") return;
236
+ if (this.#effectiveLayout() === "map" && this.#step === "pref") this.#back();
237
+ else if (this.#inline) return; // 常時表示では閉じるものが無い
238
+ else { this.#close(); this.shadowRoot.querySelector(".trigger")?.focus(); }
239
+ e.preventDefault();
240
+ };
241
+
242
+ // --- 描画 ---
243
+ #wasOpen = false;
244
+ #heightTimer = 0;
245
+ #onHeightEnd = (e) => {
246
+ if (e.propertyName === "height") this.#releaseHeight(e.currentTarget);
247
+ };
248
+ #releaseHeight(panel) {
249
+ clearTimeout(this.#heightTimer); this.#heightTimer = 0;
250
+ panel.style.height = "";
251
+ }
252
+
253
+ /**
254
+ * 中身の入れ替えで高さが変わるとき、その差を補間する。
255
+ * 地図と一覧では高さが大きく違い、そのまま入れ替えると跳ねる。
256
+ */
257
+ #renderPanelAnimated() {
258
+ const panel = this.shadowRoot.querySelector(".panel");
259
+ const from = panel.getBoundingClientRect().height;
260
+ panel.style.height = "";
261
+ this.#renderPanel();
262
+ if (!this.#open || !this.#wasOpen || !from) return;
263
+ const to = panel.getBoundingClientRect().height;
264
+ if (Math.abs(to - from) < 2) return;
265
+ // 遷移しない設定(transition="none" / prefers-reduced-motion)では高さを固定しない。
266
+ // 固定すると transitionend が来ず、その高さのまま張り付く
267
+ const ms = durationMs(getComputedStyle(panel).getPropertyValue("--_d"));
268
+ if (!ms) return;
269
+ panel.style.height = `${from}px`;
270
+ void panel.getBoundingClientRect(); // 開始の高さを確定させてから
271
+ panel.style.height = `${to}px`;
272
+ // 遷移が完了しなかった場合の保険(非表示タブなど)
273
+ clearTimeout(this.#heightTimer);
274
+ this.#heightTimer = setTimeout(() => this.#releaseHeight(panel), ms + 120);
275
+ }
276
+
277
+ update() {
278
+ const r = this.shadowRoot;
279
+ if (!r.firstChild) {
280
+ r.innerHTML = `<div class="scrim" part="scrim"></div>
281
+ <button class="trigger" part="trigger" type="button" aria-haspopup="dialog"></button>
282
+ <div class="panel" part="panel" role="dialog"></div>`;
283
+ r.querySelector(".trigger").addEventListener("click", (e) => {
284
+ e.stopPropagation(); this.#open ? this.#close() : this.#openPanel();
285
+ });
286
+ r.querySelector(".scrim").addEventListener("click", () => this.#close());
287
+ r.querySelector(".panel").addEventListener("transitionend", this.#onHeightEnd);
288
+ }
289
+ r.querySelector(".trigger").hidden = this.#inline;
290
+ r.querySelector(".scrim").hidden = this.#inline;
291
+ if (!this.#inline) this.#renderTrigger();
292
+ this.#renderPanelAnimated();
293
+ this.#wasOpen = this.#open;
294
+ r.querySelector(".scrim").classList.toggle("open", this.#open && !this.#inline);
295
+ r.querySelector(".panel").classList.toggle("open", this.#open);
296
+ this.#setFormValue();
297
+ }
298
+
299
+ #renderTrigger() {
300
+ const t = this.shadowRoot.querySelector(".trigger");
301
+ const n = this.#sel.length;
302
+ const label = !n ? `<span class="ph">${esc(this.placeholder)}</span>`
303
+ : n === 1 ? esc(this.#labelOf(this.#sel[0]))
304
+ : `${esc(this.#labelOf(this.#sel[0]))}<span class="more"> 他 ${n - 1} 件</span>`;
305
+ const v = this.#emitValue();
306
+ const shown = !n ? "" : this.multiple ? `${n}件` : String(v);
307
+ t.innerHTML = `<span>${label}</span><span class="emit">${esc(shown)}</span><span class="caret">▾</span>`;
308
+ t.disabled = this.disabled;
309
+ t.setAttribute("aria-expanded", String(this.#open));
310
+ if (this.required && !n) t.setAttribute("aria-invalid", "true"); else t.removeAttribute("aria-invalid");
311
+ }
312
+
313
+ #renderPanel() {
314
+ const p = this.shadowRoot.querySelector(".panel");
315
+ const g = this.#group, lay = this.#effectiveLayout();
316
+ const canMap = !!(g && g.geo);
317
+ const zoomed = lay === "map" && this.#step === "pref";
318
+
319
+ let h = `<div class="head">`;
320
+ if (zoomed) h += `<button class="back" type="button">← 全国</button>`;
321
+ h += `<span class="here"></span>`
322
+ + `<div class="modes" role="group" aria-label="表示の切り替え">`
323
+ + `<button type="button" data-lay="map"${canMap ? "" : " disabled"} aria-pressed="${this.layout === "map"}">地図</button>`
324
+ + `<button type="button" data-lay="list" aria-pressed="${this.layout === "list"}">リスト</button></div>`
325
+ + (this.#inline ? "" : `<button class="close" type="button" aria-label="閉じる">✕</button>`)
326
+ + `</div>`;
327
+ if (this.searchable && !this.#isArea)
328
+ h += `<div class="search-row"><input class="search" type="search" placeholder="とうきょう / tokyo / 東京" value="${esc(this.#query)}"></div>`;
329
+ h += lay === "map" ? this.#mapHtml() : this.#listHtml();
330
+ const foot = this.#footParts;
331
+ if (foot.length)
332
+ h += `<div class="foot">`
333
+ + (foot.includes("count") ? `<span class="count">${this.#sel.length} 件選択中</span>` : "")
334
+ + (foot.includes("clear") ? `<button class="fbtn" type="button" data-act="clear"${this.#sel.length ? "" : " disabled"}>クリア</button>` : "")
335
+ + (foot.includes("done") ? `<button class="fbtn primary" type="button" data-act="done">完了</button>` : "")
336
+ + `</div>`;
337
+ p.innerHTML = h;
338
+ p.setAttribute("aria-label", zoomed ? `${g.labels[this.#area]} の都道府県` : "都道府県を選ぶ");
339
+
340
+ const here = p.querySelector(".here");
341
+ const setHere = (t) => { here.textContent = t; };
342
+ setHere(zoomed ? g.labels[this.#area] : lay === "map" ? "エリアを選ぶ" : (this.#isArea ? "エリアを選ぶ" : "都道府県を選ぶ"));
343
+
344
+ p.querySelector(".back")?.addEventListener("click", (e) => { e.stopPropagation(); this.#back(); });
345
+ p.querySelector(".close")?.addEventListener("click", (e) => { e.stopPropagation(); this.#close(); });
346
+ p.querySelectorAll("[data-lay]").forEach((b) => b.addEventListener("click", (e) => {
347
+ e.stopPropagation(); if (b.disabled) return;
348
+ this.layout = b.dataset.lay; this.#step = "area"; this.#area = null; this.updateNow();
349
+ }));
350
+ p.querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", (e) => {
351
+ e.stopPropagation();
352
+ if (b.dataset.act === "clear") { this.#sel = []; this.#commit(); this.updateNow(); }
353
+ else this.#close();
354
+ }));
355
+ const q = p.querySelector(".search");
356
+ if (q) {
357
+ q.addEventListener("input", (e) => { this.#query = e.target.value; this.updateNow(); q.focus(); });
358
+ if (this.#query) { q.focus(); q.setSelectionRange(q.value.length, q.value.length); }
359
+ }
360
+ this.#bindTargets(p, zoomed, setHere);
361
+ this.#applyZoom(p);
362
+ }
363
+
364
+ /**
365
+ * 押せるものにハンドラを付ける。
366
+ * 地図と一覧は**別々に**辿る。まとめて `[data-area]` を拾うと、
367
+ * 一覧の項目(同じ属性を持つ)に二重に付き、1 回のクリックで 2 回トグルしてしまう。
368
+ */
369
+ #bindTargets(p, zoomed, setHere) {
370
+ const g = this.#group, m = this.#members;
371
+ const mark = (sel, on) => p.querySelectorAll(sel).forEach((n) => n.classList.toggle("hot", on));
372
+ const ring = (codes) => {
373
+ const r = p.querySelector(".ring.hot");
374
+ if (r) r.setAttribute("d", codes ? codes.map((c) => PATHS[c].d).join("") : "");
375
+ };
376
+ const rest = () => setHere(zoomed ? g.labels[this.#area] : "エリアを選ぶ");
377
+ const mapRoot = p.querySelector(".mapbox");
378
+
379
+ if (mapRoot && !zoomed) {
380
+ // 全国: エリア単位で押す・光らせる
381
+ mapRoot.querySelectorAll("[data-area]").forEach((n) => {
382
+ const k = n.dataset.area;
383
+ n.addEventListener("click", (e) => { e.stopPropagation(); this.#pickArea(k); });
384
+ n.addEventListener("mouseenter", () => { mark(`[data-area="${k}"]`, true); if (!this.#isArea) ring(m[k]); setHere(g.labels[k]); });
385
+ n.addEventListener("mouseleave", () => { mark(`[data-area="${k}"]`, false); ring(null); rest(); });
386
+ n.addEventListener("focus", () => { mark(`[data-area="${k}"]`, true); setHere(g.labels[k]); });
387
+ n.addEventListener("blur", () => { mark(`[data-area="${k}"]`, false); rest(); });
388
+ });
389
+ } else if (mapRoot) {
390
+ // 拡大中: 県単位で押す・光らせる
391
+ mapRoot.querySelectorAll("[data-code]").forEach((n) => {
392
+ if (n.classList.contains("dim")) return;
393
+ const c = n.dataset.code;
394
+ n.addEventListener("click", (e) => { e.stopPropagation(); this.#toggle(c); });
395
+ n.addEventListener("mouseenter", () => { mark(`[data-code="${c}"]`, true); ring([c]); setHere(this.#byCode(c).name); });
396
+ n.addEventListener("mouseleave", () => { mark(`[data-code="${c}"]`, false); ring(null); rest(); });
397
+ });
398
+ // 対象外(海・薄いエリア)を押したら全国へ戻す。ダイアログは閉じない
399
+ mapRoot.addEventListener("click", (e) => { e.stopPropagation(); this.#back(); });
400
+ }
401
+
402
+ // 一覧は自分の項目だけを見る
403
+ p.querySelectorAll(".opt[data-code]").forEach((n) =>
404
+ n.addEventListener("click", (e) => { e.stopPropagation(); this.#toggle(n.dataset.code); }));
405
+ p.querySelectorAll(".opt[data-area]").forEach((n) =>
406
+ n.addEventListener("click", (e) => { e.stopPropagation(); this.#pickArea(n.dataset.area); }));
407
+ }
408
+
409
+ /** 全国の位置で描いてから目標へ動かす。可視性は遷移の終了と同時に切り替わる */
410
+ #applyZoom(p) {
411
+ const zg = p.querySelector(".zoom");
412
+ if (!zg || !this.__t) return;
413
+ const t = this.__t;
414
+ void zg.getBoundingClientRect();
415
+ zg.style.transform = `translate(${t.x}px,${t.y}px) scale(${t.s})`;
416
+ this.#lastT = t;
417
+ }
418
+
419
+ #mapHtml() {
420
+ const g = this.#group, keys = Object.keys(g.labels), m = this.#members;
421
+ const idx = Object.fromEntries(keys.map((k, i) => [k, i]));
422
+ const hue = (k) => `var(--color-area-${(idx[k] % 11) + 1}, #7a90a8)`;
423
+ const I = VIEW_BOX.inset;
424
+ const okKey = g.keyOf(this.#byCode("47"));
425
+ const insetHtml = `<rect class="inset" x="${I.x}" y="${I.y}" width="${I.w}" height="${I.h}" rx="8"/>`
426
+ + `<rect class="inset-hit" x="${I.x}" y="${I.y}" width="${I.w}" height="${I.h}" rx="8"`
427
+ + ` data-area="${okKey}" role="button" tabindex="0" aria-label="${esc(g.labels[okKey])}"/>`;
428
+ const svg = (inner, label) => `<div class="mapbox"><svg viewBox="0 0 ${VIEW_BOX.w} ${VIEW_BOX.h}"`
429
+ + ` role="group" aria-label="${esc(label)}">${inner}</svg></div>`;
430
+
431
+ if (this.#isArea) {
432
+ // エリアは 1 面として描き、県境を描かない。選択中は最後に濃い縁で描く
433
+ const selSet = new Set(this.#sel);
434
+ const order = [...keys.filter((k) => m[k]?.length && !selSet.has(k)),
435
+ ...keys.filter((k) => m[k]?.length && selSet.has(k))];
436
+ let body = "";
437
+ for (const k of order) {
438
+ const d = m[k].map((c) => PATHS[c].d).join(""), on = selSet.has(k);
439
+ body += `<path class="area-edge${on ? " sel" : ""}" d="${d}" vector-effect="non-scaling-stroke"/>`
440
+ + `<path class="area-fill${on ? " sel" : ""}" d="${d}" data-area="${k}" style="--tc:${hue(k)}"`
441
+ + ` role="button" tabindex="0" aria-pressed="${on}"`
442
+ + ` aria-label="${esc(g.labels[k])} ${m[k].length}県"><title>${esc(g.labels[k])}</title></path>`;
443
+ }
444
+ this.__t = { s: 1, x: 0, y: 0 };
445
+ return svg(`${insetHtml}<g class="zoom">${body}${this.#checks(m, 1)}</g>`, "日本地図からエリアを選ぶ");
446
+ }
447
+
448
+ const zoomed = this.#step === "pref" && this.#area;
449
+ let t = { s: 1, x: 0, y: 0 };
450
+ if (zoomed) {
451
+ const bs = m[this.#area].map((c) => PATHS[c].b), pad = 26;
452
+ const x0 = Math.min(...bs.map((b) => b[0])) - pad, y0 = Math.min(...bs.map((b) => b[1])) - pad;
453
+ const x1 = Math.max(...bs.map((b) => b[2])) + pad, y1 = Math.max(...bs.map((b) => b[3])) + pad;
454
+ const s = Math.min(VIEW_BOX.w / (x1 - x0), VIEW_BOX.h / (y1 - y0));
455
+ t = { s, x: VIEW_BOX.w / 2 - s * (x0 + x1) / 2, y: VIEW_BOX.h / 2 - s * (y0 + y1) / 2 };
456
+ }
457
+ this.__t = t;
458
+ const f = this.#lastT;
459
+ const SC = new Set(this.#selectedCodes());
460
+ let body = "";
461
+ for (const pr of this.#prefs) {
462
+ const k = g.keyOf(pr), dim = zoomed && k !== this.#area;
463
+ const sel = SC.has(pr.code) && (!zoomed || k === this.#area); // 強調は選択した県だけ
464
+ body += `<path class="pref${dim ? " dim" : ""}${sel ? " sel" : ""}" d="${PATHS[pr.code].d}"`
465
+ + ` data-code="${pr.code}" data-area="${k}" style="--tc:${hue(k)}" vector-effect="non-scaling-stroke"`
466
+ + ` role="button" tabindex="${dim ? -1 : 0}"><title>${esc(pr.name)}</title></path>`;
467
+ }
468
+ let labels = "";
469
+ if (zoomed) for (const c of m[this.#area]) {
470
+ const [cx, cy] = PATHS[c].c;
471
+ labels += `<text class="label" x="${cx}" y="${cy}" font-size="${34 / t.s}">${esc(this.#byCode(c).shortName)}</text>`;
472
+ }
473
+ const selD = (zoomed ? [...SC].filter((c) => g.keyOf(this.#byCode(c)) === this.#area) : [...SC])
474
+ .map((c) => PATHS[c].d).join("");
475
+ return svg(`${zoomed ? "" : insetHtml}<g class="zoom" style="transform:translate(${f.x}px,${f.y}px) scale(${f.s})">`
476
+ + `<path class="ring sel" d="${selD}" vector-effect="non-scaling-stroke"/>`
477
+ + `<path class="ring hot" d="" vector-effect="non-scaling-stroke"/>`
478
+ + `${body}${labels}${this.#checks(m, t.s)}</g>`,
479
+ zoomed ? `${g.labels[this.#area]} の都道府県` : "日本地図");
480
+ }
481
+
482
+ /** 選択中の目印。色に依存しない手がかり。図形の中心に置く */
483
+ #checks(m, scale) {
484
+ const pts = this.#isArea
485
+ ? this.#sel.map((k) => areaCenter(m[k] ?? []))
486
+ : this.#selectedCodes()
487
+ .filter((c) => this.#step !== "pref" || this.#group.keyOf(this.#byCode(c)) === this.#area)
488
+ .map((c) => PATHS[c].c);
489
+ return pts.filter(Boolean).map(([cx, cy]) => {
490
+ const R = 13 / scale, w = 2.2 / scale;
491
+ return `<g class="check" aria-hidden="true"><circle cx="${cx}" cy="${cy}" r="${R}" stroke-width="${w}"/>`
492
+ + `<path d="M${cx - R * .44},${cy + R * .04} l${R * .32},${R * .36} l${R * .58},-${R * .7}" stroke-width="${w * 1.4}"/></g>`;
493
+ }).join("");
494
+ }
495
+
496
+ #listHtml() {
497
+ const g = this.#group;
498
+ if (this.#isArea && g) {
499
+ const m = this.#members;
500
+ return `<div class="list" role="listbox">` + Object.keys(g.labels).filter((k) => m[k]?.length)
501
+ .map((k) => `<button class="opt" type="button" role="option" data-area="${k}" aria-selected="${this.#sel.includes(k)}">`
502
+ + `<span class="code"></span><span>${esc(g.labels[k])}</span><span class="kana">${m[k].length}県</span></button>`)
503
+ .join("") + `</div>`;
504
+ }
505
+ const hits = this.searchable && this.#query.trim() ? match(this.#prefs, this.#query) : null;
506
+ const one = (p) => `<button class="opt" type="button" role="option" data-code="${p.code}" aria-selected="${this.#sel.includes(p.code)}">`
507
+ + `<span class="code">${p.code}</span><span>${esc(p.name)}</span><span class="kana">${esc(p.kanaShort)}</span></button>`;
508
+ if (hits && !hits.length) return `<div class="empty">「${esc(this.#query)}」に一致する都道府県はありません</div>`;
509
+ if (hits) return `<div class="list" role="listbox">${hits.map(one).join("")}</div>`;
510
+ if (!g) return `<div class="list" role="listbox">${this.#prefs.map(one).join("")}</div>`;
511
+ const m = this.#members;
512
+ return `<div class="list" role="listbox">` + Object.keys(g.labels).filter((k) => m[k]?.length)
513
+ .map((k) => `<div class="group-head">${esc(g.labels[k])}</div>` + m[k].map((c) => one(this.#byCode(c))).join(""))
514
+ .join("") + `</div>`;
515
+ }
516
+ }
517
+
518
+ customElements.define("yn-prefecture-picker", YnPrefecturePicker);
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ /** ui-labo のプレーン Web 実装。import しただけでカスタム要素が登録される */
2
+ export { YnPrefecturePicker } from "./components/prefecture-picker/yn-prefecture-picker.js";
@@ -0,0 +1,62 @@
1
+ /** 区切り(grouping)の定義。仕様は spec `prefecture-picker` の「区切り」 */
2
+
3
+ const AREA_KEY = {
4
+ "1-HKD": "hokkaido", "2-THK": "tohoku", "3-KTO": "kanto", "4-CHB": "chubu",
5
+ "5-KNK": "kinki", "6-CGK": "chugoku", "7-SKK": "shikoku", "8-KYS": "kyushu", "9-OKA": "okinawa",
6
+ };
7
+ const R9 = { hokkaido:"北海道", tohoku:"東北", kanto:"関東", chubu:"中部", kinki:"近畿",
8
+ chugoku:"中国", shikoku:"四国", kyushu:"九州", okinawa:"沖縄" };
9
+ const R8 = { ...R9, kyushu: "九州・沖縄" };
10
+ delete R8.okinawa;
11
+ const R11 = { hokkaido:"北海道", tohoku:"東北", kanto:"関東", hokuriku:"北陸", koshinetsu:"甲信越",
12
+ tokai:"東海", kinki:"近畿", chugoku:"中国", shikoku:"四国", kyushu:"九州", okinawa:"沖縄" };
13
+
14
+ /** region11 では中部を 3 つに分け、三重を近畿から東海へ移す。region9 との差は意図的 */
15
+ function region11(code) {
16
+ const n = Number(code);
17
+ if (n === 1) return "hokkaido";
18
+ if (n <= 7) return "tohoku";
19
+ if (n <= 14) return "kanto";
20
+ if (n === 15 || n === 19 || n === 20) return "koshinetsu";
21
+ if (n <= 18) return "hokuriku";
22
+ if (n <= 24) return "tokai";
23
+ if (n <= 30) return "kinki";
24
+ if (n <= 35) return "chugoku";
25
+ if (n <= 39) return "shikoku";
26
+ if (n <= 46) return "kyushu";
27
+ return "okinawa";
28
+ }
29
+
30
+ const KANA_ROWS = [
31
+ ["a","あ行","あいうえお"], ["ka","か行","かきくけこがぎぐげご"],
32
+ ["sa","さ行","さしすせそざじずぜぞ"], ["ta","た行","たちつてとだぢづでど"],
33
+ ["na","な行","なにぬねの"], ["ha","は行","はひふへほばびぶべぼぱぴぷぺぽ"],
34
+ ["ma","ま行","まみむめも"], ["ya","や行","やゆよ"],
35
+ ["ra","ら行","らりるれろ"], ["wa","わ行","わをん"],
36
+ ];
37
+
38
+ /**
39
+ * @returns {{labels: Record<string,string>, keyOf: (p) => string, geo: boolean} | null}
40
+ * geo が false の区切りは地図を持たないため、layout="map" でも一覧として描く
41
+ */
42
+ export function grouping(name) {
43
+ switch (name) {
44
+ case "region9": return { labels: R9, keyOf: (p) => AREA_KEY[p.areaCode], geo: true };
45
+ case "region8": return { labels: R8, keyOf: (p) => { const k = AREA_KEY[p.areaCode]; return k === "okinawa" ? "kyushu" : k; }, geo: true };
46
+ case "region11": return { labels: R11, keyOf: (p) => region11(p.code), geo: true };
47
+ case "kana": return {
48
+ labels: Object.fromEntries(KANA_ROWS.map(([k, l]) => [k, l])),
49
+ keyOf: (p) => (KANA_ROWS.find((r) => r[2].includes(p.kanaShort[0])) ?? KANA_ROWS.at(-1))[0],
50
+ geo: false,
51
+ };
52
+ case "none": return null;
53
+ default: return null;
54
+ }
55
+ }
56
+
57
+ /** 区切りごとの所属。キーの並びは labels の順を保つ */
58
+ export function members(g, prefectures) {
59
+ const map = Object.fromEntries(Object.keys(g.labels).map((k) => [k, []]));
60
+ for (const p of prefectures) (map[g.keyOf(p)] ??= []).push(p.code);
61
+ return map;
62
+ }
@@ -0,0 +1,41 @@
1
+ /** codeFormat による値の表現。入出力で対称にする(spec `prefecture-picker` の「値の表現」) */
2
+
3
+ export function formatPrefecture(pref, codeFormat) {
4
+ switch (codeFormat) {
5
+ case "jisNumber": return Number(pref.code);
6
+ case "ynetlabo": return pref.ynetlaboCode;
7
+ case "iso": return "JP-" + pref.code;
8
+ case "name": return pref.name;
9
+ case "shortName": return pref.shortName;
10
+ default: return pref.code;
11
+ }
12
+ }
13
+
14
+ /** エリアの値。region9 のときだけ ynetlabo のエリアコードと一致する */
15
+ export function formatArea(key, { labels, memberCodes, byCode, codeFormat }) {
16
+ if (codeFormat === "name" || codeFormat === "shortName") return labels[key];
17
+ if (codeFormat === "ynetlabo") return byCode(memberCodes[0])?.areaCode ?? key;
18
+ return key;
19
+ }
20
+
21
+ /** 表示に使う表記。codeFormat とは独立して指定できる */
22
+ export const displayOf = (pref, display) => (display === "shortName" ? pref.shortName : pref.name);
23
+
24
+ /** 受け取った値を内部のキー(県コード / エリアキー)に戻す。不正な値は未選択として扱う */
25
+ export function toKey(value, { level, prefectures, grouping, memberMap }) {
26
+ if (value === null || value === undefined || value === "") return null;
27
+ const s = String(value);
28
+ if (level === "area") {
29
+ if (memberMap[s]) return s;
30
+ for (const [k, codes] of Object.entries(memberMap)) {
31
+ const first = prefectures.find((p) => p.code === codes[0]);
32
+ if (first?.areaCode === s) return k;
33
+ if (grouping.labels[k] === s) return k;
34
+ }
35
+ return null;
36
+ }
37
+ const n = s.replace(/^JP-/, "");
38
+ const hit = prefectures.find((p) =>
39
+ p.code === n.padStart(2, "0") || p.ynetlaboCode === s || p.name === s || p.shortName === s);
40
+ return hit ? hit.code : null;
41
+ }