@shwfed/config 3.0.19 → 3.0.21

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/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shwfed",
3
3
  "configKey": "shwfed",
4
- "version": "3.0.19",
4
+ "version": "3.0.21",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -5,14 +5,14 @@ type __VLS_ModelProps = {
5
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
6
  "update:modelValue": (value: {
7
7
  readonly type: "com.shwfed.actions.button.navigation";
8
- readonly mode: "_blank" | "_self";
8
+ readonly mode: "_self" | "_blank";
9
9
  readonly url: string;
10
10
  readonly compatibilityDate: "2026-04-21";
11
11
  }) => any;
12
12
  }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
13
13
  "onUpdate:modelValue"?: ((value: {
14
14
  readonly type: "com.shwfed.actions.button.navigation";
15
- readonly mode: "_blank" | "_self";
15
+ readonly mode: "_self" | "_blank";
16
16
  readonly url: string;
17
17
  readonly compatibilityDate: "2026-04-21";
18
18
  }) => any) | undefined;
@@ -5,14 +5,14 @@ type __VLS_ModelProps = {
5
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
6
  "update:modelValue": (value: {
7
7
  readonly type: "com.shwfed.actions.button.navigation";
8
- readonly mode: "_blank" | "_self";
8
+ readonly mode: "_self" | "_blank";
9
9
  readonly url: string;
10
10
  readonly compatibilityDate: "2026-04-21";
11
11
  }) => any;
12
12
  }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
13
13
  "onUpdate:modelValue"?: ((value: {
14
14
  readonly type: "com.shwfed.actions.button.navigation";
15
- readonly mode: "_blank" | "_self";
15
+ readonly mode: "_self" | "_blank";
16
16
  readonly url: string;
17
17
  readonly compatibilityDate: "2026-04-21";
18
18
  }) => any) | undefined;
@@ -137,7 +137,14 @@ const gridStyle = computed(() => {
137
137
  const rendered = computed(() => {
138
138
  const comp = compaction.value;
139
139
  if (!comp) return [];
140
- return placedFields.value.map((p) => ({
140
+ const ordered = placedFields.value.slice().sort((a, b) => {
141
+ const [[ax1, ay1]] = a.placement.area;
142
+ const [[bx1, by1]] = b.placement.area;
143
+ if (ay1 !== by1) return ay1 - by1;
144
+ if (ax1 !== bx1) return ax1 - bx1;
145
+ return 0;
146
+ });
147
+ return ordered.map((p) => ({
141
148
  key: p.field.id,
142
149
  style: cellStyle(p.placement, comp),
143
150
  component: p.entry.runtime,
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue";
3
3
  import { watchDebounced } from "@vueuse/core";
4
4
  import { Effect, Option } from "effect";
5
5
  import { Fetch } from "fx-fetch";
6
- import { computed, nextTick, ref } from "vue";
6
+ import { computed, nextTick, onScopeDispose, ref } from "vue";
7
7
  import { useI18n } from "vue-i18n";
8
8
  import { asRequest, fetchJsonOption } from "../../../../../share/request";
9
9
  import { cel as _rawCel } from "../../../../../utils/cel";
@@ -23,6 +23,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton } from "../../../../ui/in
23
23
  import { Markdown } from "../../../../ui/markdown";
24
24
  import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "../../../../ui/popover";
25
25
  import { DEFAULT_FIELD_ORIENTATION } from "../../../utils/common";
26
+ import { useDerivedQuiescence } from "../../../utils/derived";
26
27
  import { useFieldValidation } from "../../../utils/validation";
27
28
  import { useFormReadonly } from "../../../utils/readonly";
28
29
  import { useFormScope, useFormState } from "../../../utils/state";
@@ -51,7 +52,7 @@ const { locale, t } = useI18n({
51
52
  }
52
53
  }
53
54
  });
54
- const { getAt, setAt, setAtSilent } = useFormState();
55
+ const { getAt, setAt, setAtSilent, seeded } = useFormState();
55
56
  const formScope = useFormScope();
56
57
  const inherited = injectCELContext();
57
58
  const $cel = (expression, context) => _rawCel(expression, { ...celBindings(inherited), ...context });
@@ -163,20 +164,34 @@ const model = computed({
163
164
  setAt(path, next);
164
165
  }
165
166
  });
167
+ let seedSettled = false;
166
168
  let lastRequestSignature;
167
- watchDebounced(
168
- requestSignature,
169
- () => {
170
- if (lastRequestSignature != null) {
171
- const path = props.config.binding;
172
- if (path == null) uncontrolled.value = void 0;
173
- else setAtSilent(path, void 0);
174
- }
175
- lastRequestSignature = requestSignature.value;
169
+ let lastFetchedSignature;
170
+ function reactToRequest() {
171
+ if (!seedSettled) return;
172
+ if (lastRequestSignature != null && requestSignature.value !== lastRequestSignature) {
173
+ const path = props.config.binding;
174
+ if (path == null) uncontrolled.value = void 0;
175
+ else setAtSilent(path, void 0);
176
+ }
177
+ lastRequestSignature = requestSignature.value;
178
+ if (requestSignature.value !== lastFetchedSignature) {
179
+ lastFetchedSignature = requestSignature.value;
176
180
  void fetchOptions();
177
- },
178
- { debounce: 200, immediate: true }
179
- );
181
+ }
182
+ }
183
+ watchDebounced(requestSignature, reactToRequest, { debounce: 200, immediate: true });
184
+ const quiescence = useDerivedQuiescence();
185
+ let disposed = false;
186
+ onScopeDispose(() => {
187
+ disposed = true;
188
+ });
189
+ function establishBaseline() {
190
+ if (disposed) return;
191
+ seedSettled = true;
192
+ reactToRequest();
193
+ }
194
+ seeded.then(() => quiescence.settled(), () => quiescence.settled()).then(establishBaseline);
180
195
  function isEqual(a, b) {
181
196
  if (a === b) return true;
182
197
  if (a == null || b == null) return false;
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue";
3
3
  import { watchDebounced } from "@vueuse/core";
4
4
  import { Effect, Option } from "effect";
5
5
  import { Fetch } from "fx-fetch";
6
- import { computed, nextTick, ref, watch } from "vue";
6
+ import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
7
7
  import { useI18n } from "vue-i18n";
8
8
  import { asRequest, fetchJsonOption } from "../../../../../share/request";
9
9
  import { cel as _rawCel } from "../../../../../utils/cel";
@@ -24,6 +24,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "
24
24
  import { Markdown } from "../../../../ui/markdown";
25
25
  import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "../../../../ui/popover";
26
26
  import { DEFAULT_FIELD_ORIENTATION } from "../../../utils/common";
27
+ import { useDerivedQuiescence } from "../../../utils/derived";
27
28
  import { useFormReadonly } from "../../../utils/readonly";
28
29
  import { useFormScope, useFormState } from "../../../utils/state";
29
30
  import { useFieldValidation } from "../../../utils/validation";
@@ -55,7 +56,7 @@ const { locale, t } = useI18n({
55
56
  }
56
57
  }
57
58
  });
58
- const { getAt, setAt, setAtSilent } = useFormState();
59
+ const { getAt, setAt, setAtSilent, seeded } = useFormState();
59
60
  const formScope = useFormScope();
60
61
  const inherited = injectCELContext();
61
62
  const $cel = (expression, context) => _rawCel(expression, { ...celBindings(inherited), ...context });
@@ -220,20 +221,34 @@ const model = computed({
220
221
  setAt(path, next);
221
222
  }
222
223
  });
224
+ let seedSettled = false;
223
225
  let lastRequestSignature;
224
- watchDebounced(
225
- requestSignature,
226
- () => {
227
- if (lastRequestSignature != null) {
228
- const path = props.config.binding;
229
- if (path == null) uncontrolled.value = void 0;
230
- else setAtSilent(path, void 0);
231
- }
232
- lastRequestSignature = requestSignature.value;
226
+ let lastFetchedSignature;
227
+ function reactToRequest() {
228
+ if (!seedSettled) return;
229
+ if (lastRequestSignature != null && requestSignature.value !== lastRequestSignature) {
230
+ const path = props.config.binding;
231
+ if (path == null) uncontrolled.value = void 0;
232
+ else setAtSilent(path, void 0);
233
+ }
234
+ lastRequestSignature = requestSignature.value;
235
+ if (requestSignature.value !== lastFetchedSignature) {
236
+ lastFetchedSignature = requestSignature.value;
233
237
  void fetchOptions();
234
- },
235
- { debounce: 200, immediate: true }
236
- );
238
+ }
239
+ }
240
+ watchDebounced(requestSignature, reactToRequest, { debounce: 200, immediate: true });
241
+ const quiescence = useDerivedQuiescence();
242
+ let disposed = false;
243
+ onScopeDispose(() => {
244
+ disposed = true;
245
+ });
246
+ function establishBaseline() {
247
+ if (disposed) return;
248
+ seedSettled = true;
249
+ reactToRequest();
250
+ }
251
+ seeded.then(() => quiescence.settled(), () => quiescence.settled()).then(establishBaseline);
237
252
  function isEqual(a, b) {
238
253
  if (a === b) return true;
239
254
  if (a == null || b == null) return false;
@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue";
3
3
  import { watchDebounced } from "@vueuse/core";
4
4
  import { Effect, Option } from "effect";
5
5
  import { Fetch } from "fx-fetch";
6
- import { computed, nextTick, ref, watch } from "vue";
6
+ import { computed, nextTick, onScopeDispose, ref, watch } from "vue";
7
7
  import { useI18n } from "vue-i18n";
8
8
  import { asRequest, fetchJsonOption } from "../../../../../share/request";
9
9
  import { cel as _rawCel } from "../../../../../utils/cel";
@@ -24,6 +24,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "
24
24
  import { Markdown } from "../../../../ui/markdown";
25
25
  import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "../../../../ui/popover";
26
26
  import { DEFAULT_FIELD_ORIENTATION } from "../../../utils/common";
27
+ import { useDerivedQuiescence } from "../../../utils/derived";
27
28
  import { useFormReadonly } from "../../../utils/readonly";
28
29
  import { useFormScope, useFormState } from "../../../utils/state";
29
30
  import { useFieldValidation } from "../../../utils/validation";
@@ -55,7 +56,7 @@ const { locale, t } = useI18n({
55
56
  }
56
57
  }
57
58
  });
58
- const { getAt, setAt, setAtSilent } = useFormState();
59
+ const { getAt, setAt, setAtSilent, seeded } = useFormState();
59
60
  const formScope = useFormScope();
60
61
  const inherited = injectCELContext();
61
62
  const $cel = (expression, context) => _rawCel(expression, { ...celBindings(inherited), ...context });
@@ -223,20 +224,34 @@ const model = computed({
223
224
  setAt(path, next);
224
225
  }
225
226
  });
227
+ let seedSettled = false;
226
228
  let lastRequestSignature;
227
- watchDebounced(
228
- requestSignature,
229
- () => {
230
- if (lastRequestSignature != null) {
231
- const path = props.config.binding;
232
- if (path == null) uncontrolled.value = void 0;
233
- else setAtSilent(path, void 0);
234
- }
235
- lastRequestSignature = requestSignature.value;
229
+ let lastFetchedSignature;
230
+ function reactToRequest() {
231
+ if (!seedSettled) return;
232
+ if (lastRequestSignature != null && requestSignature.value !== lastRequestSignature) {
233
+ const path = props.config.binding;
234
+ if (path == null) uncontrolled.value = void 0;
235
+ else setAtSilent(path, void 0);
236
+ }
237
+ lastRequestSignature = requestSignature.value;
238
+ if (requestSignature.value !== lastFetchedSignature) {
239
+ lastFetchedSignature = requestSignature.value;
236
240
  void fetchOptions();
237
- },
238
- { debounce: 200, immediate: true }
239
- );
241
+ }
242
+ }
243
+ watchDebounced(requestSignature, reactToRequest, { debounce: 200, immediate: true });
244
+ const quiescence = useDerivedQuiescence();
245
+ let disposed = false;
246
+ onScopeDispose(() => {
247
+ disposed = true;
248
+ });
249
+ function establishBaseline() {
250
+ if (disposed) return;
251
+ seedSettled = true;
252
+ reactToRequest();
253
+ }
254
+ seeded.then(() => quiescence.settled(), () => quiescence.settled()).then(establishBaseline);
240
255
  function isEqual(a, b) {
241
256
  if (a === b) return true;
242
257
  if (a == null || b == null) return false;
@@ -53,7 +53,14 @@ if (config.value.id) {
53
53
  }
54
54
  const inherited = injectCELContext();
55
55
  const $cel = (expression, context) => _rawCel(expression, { ...celBindings(inherited), ...context });
56
- const formState = provideFormState(state);
56
+ const seeded = (() => {
57
+ const current = state.value ?? {};
58
+ if (!config.value.initial || Object.keys(current).length > 0) return Promise.resolve();
59
+ return computeSeed(current).then((seed) => {
60
+ if (seed) state.value = { ...current, ...seed };
61
+ });
62
+ })();
63
+ const formState = provideFormState(state, "form", seeded);
57
64
  const commitBus = provideCommitBus();
58
65
  const quiescence = provideDerivedQuiescence();
59
66
  const formReadonly = computed(() => {
@@ -79,13 +86,6 @@ async function computeSeed(base) {
79
86
  return null;
80
87
  }
81
88
  }
82
- const seeded = (() => {
83
- const current = state.value ?? {};
84
- if (!config.value.initial || Object.keys(current).length > 0) return Promise.resolve();
85
- return computeSeed(current).then((seed) => {
86
- if (seed) state.value = { ...current, ...seed };
87
- });
88
- })();
89
89
  async function reseed() {
90
90
  const seed = await computeSeed({});
91
91
  state.value = seed ? { ...seed } : {};
@@ -33,6 +33,23 @@ export type FormStateBag = {
33
33
  * through the bags themselves.
34
34
  */
35
35
  parent: FormStateBag | undefined;
36
+ /**
37
+ * Resolves once the form's `initial` seed has landed in `state` (or
38
+ * immediately when there is no seed). The top-level form host passes its
39
+ * own seed promise; nested bags (list/table rows) inherit the enclosing
40
+ * form's, and a host-less fallback bag gets an already-resolved one.
41
+ *
42
+ * A field runtime awaits this before it establishes any request/watch
43
+ * baseline: while the seed is in flight `state` is empty, so a request
44
+ * expression that reads `form.?dep` builds a param-less-but-non-`null`
45
+ * request. Establishing a baseline against that pre-seed request would make
46
+ * the post-seed signature look like a "change away" and wrongly clear the
47
+ * seeded selection. Keying the first baseline off THIS boundary (not off a
48
+ * `null` signature, which `.query`'s drop-missing-param semantics defeats)
49
+ * is the correct edge. May reject when the seed itself failed — consumers
50
+ * must still start on rejection (mirror `useDerived`'s `ready` handling).
51
+ */
52
+ seeded: Promise<void>;
36
53
  state: Ref<unknown>;
37
54
  getAt: (path: string) => unknown;
38
55
  /**
@@ -83,7 +100,7 @@ export type FormStateBag = {
83
100
  coerce: (path: string, value: unknown) => unknown;
84
101
  };
85
102
  export declare const FORM_STATE_KEY: InjectionKey<FormStateBag>;
86
- export declare function provideFormState(state: Ref<unknown>, kind?: FormStateBagKind): FormStateBag;
103
+ export declare function provideFormState(state: Ref<unknown>, kind?: FormStateBagKind, seeded?: Promise<void>): FormStateBag;
87
104
  export declare function useFormState(): FormStateBag;
88
105
  /**
89
106
  * The nearest `'form'`-kind bag enclosing the current field — what the CEL
@@ -2,7 +2,7 @@ import { getProperty, setProperty } from "dot-prop";
2
2
  import { inject, provide, ref } from "vue";
3
3
  export const SELF_BINDING = ".";
4
4
  export const FORM_STATE_KEY = Symbol("shwfed-form-state");
5
- export function provideFormState(state, kind = "form") {
5
+ export function provideFormState(state, kind = "form", seeded) {
6
6
  const parent = inject(FORM_STATE_KEY, void 0);
7
7
  const dirty = ref(/* @__PURE__ */ new Set());
8
8
  const coercions = /* @__PURE__ */ new Map();
@@ -27,6 +27,10 @@ export function provideFormState(state, kind = "form") {
27
27
  const bag = {
28
28
  kind,
29
29
  parent,
30
+ // Explicit seed promise when given (the top-level form host), else inherit
31
+ // the enclosing form's (list/table rows), else an already-resolved one (a
32
+ // host-less fallback bag — the standalone table, a test harness).
33
+ seeded: seeded ?? parent?.seeded ?? Promise.resolve(),
30
34
  state,
31
35
  getAt: (path) => path === SELF_BINDING ? state.value : getProperty(state.value, path),
32
36
  setAt: (path, value) => {
@@ -4,12 +4,12 @@ type __VLS_ModelProps = {
4
4
  };
5
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
6
  "update:modelValue": (value: {
7
- readonly mode: "_blank" | "_self";
7
+ readonly mode: "_self" | "_blank";
8
8
  readonly url: string;
9
9
  }) => any;
10
10
  }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
11
11
  "onUpdate:modelValue"?: ((value: {
12
- readonly mode: "_blank" | "_self";
12
+ readonly mode: "_self" | "_blank";
13
13
  readonly url: string;
14
14
  }) => any) | undefined;
15
15
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -4,12 +4,12 @@ type __VLS_ModelProps = {
4
4
  };
5
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
6
  "update:modelValue": (value: {
7
- readonly mode: "_blank" | "_self";
7
+ readonly mode: "_self" | "_blank";
8
8
  readonly url: string;
9
9
  }) => any;
10
10
  }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
11
11
  "onUpdate:modelValue"?: ((value: {
12
- readonly mode: "_blank" | "_self";
12
+ readonly mode: "_self" | "_blank";
13
13
  readonly url: string;
14
14
  }) => any) | undefined;
15
15
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -1,7 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  modelValue?: string;
3
3
  placeholder?: string;
4
- browseUrl?: string;
5
4
  };
6
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
6
  "update:modelValue": (value: string) => any;
@@ -1,48 +1,332 @@
1
1
  <script setup>
2
2
  import { Icon } from "@iconify/vue";
3
+ import { useVirtualizer } from "@tanstack/vue-virtual";
4
+ import { useElementSize } from "@vueuse/core";
5
+ import { ScrollArea } from "reka-ui/namespaced";
6
+ import { computed, ref, shallowRef, useTemplateRef, watch } from "vue";
7
+ import { useI18n } from "vue-i18n";
8
+ import { Button } from "../button";
3
9
  import {
4
10
  InputGroup,
5
11
  InputGroupAddon,
6
12
  InputGroupButton,
7
13
  InputGroupInput
8
14
  } from "../input-group";
15
+ import { Popover, PopoverAnchor, PopoverContent } from "../popover";
16
+ import { ScrollBar } from "../scroll-area";
17
+ import { Tabs, TabsList, TabsTrigger } from "../tabs";
9
18
  const props = defineProps({
10
19
  modelValue: { type: String, required: false },
11
- placeholder: { type: String, required: false },
12
- browseUrl: { type: String, required: false }
20
+ placeholder: { type: String, required: false }
13
21
  });
14
22
  const emit = defineEmits(["update:modelValue"]);
15
- const DEFAULT_BROWSE_URL = "https://icones.js.org/collection/fluent?variant=Regular+20";
23
+ const { t } = useI18n({
24
+ inheritLocale: true,
25
+ messages: {
26
+ zh: {
27
+ "icon-picker-placeholder": "Iconify \u56FE\u6807\u6807\u8BC6\u7B26\uFF0C\u5982 fluent:add-20-regular",
28
+ "icon-picker-browse": "\u6D4F\u89C8\u56FE\u6807",
29
+ "icon-picker-clear": "\u6E05\u7A7A",
30
+ "icon-picker-regular": "\u5E38\u89C4",
31
+ "icon-picker-filled": "\u586B\u5145",
32
+ "icon-picker-loading": "\u6B63\u5728\u52A0\u8F7D\u56FE\u6807\u2026",
33
+ "icon-picker-error": "\u56FE\u6807\u52A0\u8F7D\u5931\u8D25",
34
+ "icon-picker-retry": "\u91CD\u8BD5",
35
+ "icon-picker-empty": "\u65E0\u5339\u914D\u56FE\u6807"
36
+ },
37
+ ja: {
38
+ "icon-picker-placeholder": "Iconify \u30A2\u30A4\u30B3\u30F3 ID\uFF08\u4F8B\uFF1Afluent:add-20-regular\uFF09",
39
+ "icon-picker-browse": "\u30A2\u30A4\u30B3\u30F3\u3092\u53C2\u7167",
40
+ "icon-picker-clear": "\u30AF\u30EA\u30A2",
41
+ "icon-picker-regular": "\u30EC\u30AE\u30E5\u30E9\u30FC",
42
+ "icon-picker-filled": "\u30D5\u30A3\u30EB\u30C9",
43
+ "icon-picker-loading": "\u30A2\u30A4\u30B3\u30F3\u3092\u8AAD\u307F\u8FBC\u307F\u4E2D\u2026",
44
+ "icon-picker-error": "\u30A2\u30A4\u30B3\u30F3\u306E\u8AAD\u307F\u8FBC\u307F\u306B\u5931\u6557\u3057\u307E\u3057\u305F",
45
+ "icon-picker-retry": "\u518D\u8A66\u884C",
46
+ "icon-picker-empty": "\u4E00\u81F4\u3059\u308B\u30A2\u30A4\u30B3\u30F3\u304C\u3042\u308A\u307E\u305B\u3093"
47
+ },
48
+ en: {
49
+ "icon-picker-placeholder": "Iconify icon id, e.g. fluent:add-20-regular",
50
+ "icon-picker-browse": "Browse icons",
51
+ "icon-picker-clear": "Clear",
52
+ "icon-picker-regular": "Regular",
53
+ "icon-picker-filled": "Filled",
54
+ "icon-picker-loading": "Loading icons\u2026",
55
+ "icon-picker-error": "Failed to load icons",
56
+ "icon-picker-retry": "Retry",
57
+ "icon-picker-empty": "No matching icon"
58
+ }
59
+ }
60
+ });
61
+ const ICONIFY_API = "https://api.iconify.design";
62
+ const SUFFIX = {
63
+ regular: "-20-regular",
64
+ filled: "-20-filled"
65
+ };
66
+ let stemsCache = null;
67
+ function loadStems() {
68
+ if (!stemsCache) {
69
+ stemsCache = fetch(`${ICONIFY_API}/collection?prefix=fluent`).then(async (response) => {
70
+ if (!response.ok) throw new Error(`iconify collection: HTTP ${response.status}`);
71
+ const body = await response.json();
72
+ const hidden = new Set(body.hidden ?? []);
73
+ const names = [
74
+ ...body.uncategorized ?? [],
75
+ ...Object.values(body.categories ?? {}).flat()
76
+ ].filter((name) => !hidden.has(name));
77
+ const collect = (variant2) => names.filter((name) => name.endsWith(SUFFIX[variant2])).map((name) => name.slice(0, -SUFFIX[variant2].length));
78
+ return { regular: collect("regular"), filled: collect("filled") };
79
+ }).catch((error) => {
80
+ stemsCache = null;
81
+ throw error;
82
+ });
83
+ }
84
+ return stemsCache;
85
+ }
86
+ const stems = shallowRef(null);
87
+ const status = ref("idle");
88
+ async function ensureStems() {
89
+ if (stems.value) return;
90
+ status.value = "loading";
91
+ try {
92
+ stems.value = await loadStems();
93
+ status.value = "ready";
94
+ } catch {
95
+ status.value = "error";
96
+ }
97
+ }
98
+ const tokens = computed(() => {
99
+ const raw = (props.modelValue ?? "").toLowerCase();
100
+ const body = raw.slice(raw.indexOf(":") + 1);
101
+ return body.replace(/-\d+(?:-(?:regular|filled|light))?$/, "").split(/[^a-z0-9]+/).filter(Boolean);
102
+ });
103
+ function match(variant2) {
104
+ const all = stems.value?.[variant2] ?? [];
105
+ if (!tokens.value.length) return all;
106
+ return all.filter((stem) => tokens.value.every((token) => stem.includes(token)));
107
+ }
108
+ const matches = computed(() => ({
109
+ regular: match("regular"),
110
+ filled: match("filled")
111
+ }));
112
+ const variant = ref("regular");
113
+ const results = computed(() => matches.value[variant.value]);
114
+ const tabs = computed(() => [
115
+ { value: "regular", label: t("icon-picker-regular") },
116
+ { value: "filled", label: t("icon-picker-filled") }
117
+ ]);
118
+ const open = ref(false);
119
+ const anchor = useTemplateRef("anchor");
120
+ const viewport = useTemplateRef("viewport");
121
+ const scroller = computed(() => viewport.value?.viewportElement ?? null);
122
+ function openPanel() {
123
+ if (open.value) return;
124
+ variant.value = (props.modelValue ?? "").endsWith(SUFFIX.filled) ? "filled" : "regular";
125
+ open.value = true;
126
+ void ensureStems();
127
+ }
16
128
  function onInput(value) {
17
129
  emit("update:modelValue", value == null ? "" : String(value));
130
+ openPanel();
131
+ }
132
+ function select(stem) {
133
+ emit("update:modelValue", `fluent:${stem}${SUFFIX[variant.value]}`);
134
+ open.value = false;
135
+ }
136
+ function clear() {
137
+ emit("update:modelValue", "");
138
+ openPanel();
139
+ }
140
+ function onInteractOutside(event) {
141
+ const target = event.detail?.originalEvent?.target;
142
+ if (target instanceof Node && anchor.value?.contains(target)) event.preventDefault();
18
143
  }
144
+ const CELL = 36;
145
+ const { width: scrollerWidth } = useElementSize(scroller);
146
+ const columns = computed(() => Math.max(1, Math.floor(scrollerWidth.value / CELL)));
147
+ const rowVirtualizer = useVirtualizer(
148
+ computed(() => {
149
+ const element = scroller.value;
150
+ return {
151
+ count: Math.ceil(results.value.length / columns.value),
152
+ estimateSize: () => CELL,
153
+ getScrollElement: () => element,
154
+ overscan: 4
155
+ };
156
+ })
157
+ );
158
+ const rowTotalSize = computed(() => rowVirtualizer.value.getTotalSize());
159
+ const rowWindow = computed(() => rowVirtualizer.value.getVirtualItems());
160
+ function rowStems(index) {
161
+ return results.value.slice(index * columns.value, (index + 1) * columns.value);
162
+ }
163
+ function isSelected(stem) {
164
+ return props.modelValue === `fluent:${stem}${SUFFIX[variant.value]}`;
165
+ }
166
+ watch([results, variant], () => {
167
+ if (scroller.value) scroller.value.scrollTop = 0;
168
+ });
19
169
  </script>
20
170
 
21
171
  <template>
22
- <InputGroup>
23
- <InputGroupAddon align="inline-start">
24
- <Icon
25
- :icon="props.modelValue || 'fluent:image-20-regular'"
26
- :class="props.modelValue ? 'text-zinc-600' : 'text-zinc-300'"
27
- />
28
- </InputGroupAddon>
29
- <InputGroupInput
30
- :model-value="props.modelValue ?? ''"
31
- :placeholder="props.placeholder ?? 'Iconify \u56FE\u6807\u6807\u8BC6\u7B26\uFF0C\u5982 fluent:add-20-regular'"
32
- class="font-mono text-xs"
33
- @update:model-value="onInput"
34
- />
35
- <InputGroupAddon align="inline-end">
36
- <InputGroupButton
37
- as="a"
38
- :href="props.browseUrl ?? DEFAULT_BROWSE_URL"
39
- target="_blank"
40
- rel="noopener noreferrer"
41
- size="icon-xs"
42
- aria-label="浏览图标"
172
+ <Popover v-model:open="open">
173
+ <div ref="anchor">
174
+ <PopoverAnchor as-child>
175
+ <InputGroup>
176
+ <InputGroupAddon align="inline-start">
177
+ <Icon
178
+ :icon="props.modelValue || 'fluent:image-20-regular'"
179
+ :class="props.modelValue ? 'text-zinc-600' : 'text-zinc-300'"
180
+ />
181
+ </InputGroupAddon>
182
+ <InputGroupInput
183
+ :model-value="props.modelValue ?? ''"
184
+ :placeholder="props.placeholder ?? t('icon-picker-placeholder')"
185
+ class="font-mono text-xs"
186
+ @focus="openPanel"
187
+ @click="openPanel"
188
+ @update:model-value="onInput"
189
+ />
190
+ <InputGroupAddon align="inline-end">
191
+ <InputGroupButton
192
+ v-if="props.modelValue"
193
+ size="icon-xs"
194
+ data-slot="icon-picker-clear"
195
+ :aria-label="t('icon-picker-clear')"
196
+ tabindex="-1"
197
+ class="text-zinc-500 transition-opacity hover:text-zinc-700 [@media(hover:hover)]:opacity-0 group-focus-within/input-group:opacity-100 group-hover/input-group:opacity-100"
198
+ @mousedown.prevent
199
+ @click.stop="clear"
200
+ >
201
+ <Icon icon="fluent:dismiss-20-regular" />
202
+ </InputGroupButton>
203
+ <InputGroupButton
204
+ size="icon-xs"
205
+ data-slot="icon-picker-toggle"
206
+ :aria-label="t('icon-picker-browse')"
207
+ :aria-expanded="open"
208
+ @click="open ? open = false : openPanel()"
209
+ >
210
+ <Icon
211
+ icon="fluent:chevron-down-20-regular"
212
+ class="transition-transform duration-150"
213
+ :class="open && 'rotate-180'"
214
+ />
215
+ </InputGroupButton>
216
+ </InputGroupAddon>
217
+ </InputGroup>
218
+ </PopoverAnchor>
219
+ </div>
220
+
221
+ <PopoverContent
222
+ align="start"
223
+ class="w-(--reka-popover-trigger-width) min-w-72 p-0"
224
+ @open-auto-focus="(event) => event.preventDefault()"
225
+ @interact-outside="onInteractOutside"
226
+ >
227
+ <Tabs
228
+ v-model="variant"
229
+ class="gap-0"
43
230
  >
44
- <Icon icon="fluent:search-20-regular" />
45
- </InputGroupButton>
46
- </InputGroupAddon>
47
- </InputGroup>
231
+ <TabsList class="px-3">
232
+ <TabsTrigger
233
+ v-for="tab in tabs"
234
+ :key="tab.value"
235
+ :value="tab.value"
236
+ class="h-9 px-4 text-xs"
237
+ >
238
+ {{ tab.label }}
239
+ <span class="text-[0.625rem] text-zinc-400 tabular-nums">
240
+ {{ matches[tab.value].length }}
241
+ </span>
242
+ </TabsTrigger>
243
+ </TabsList>
244
+
245
+ <!-- Only the grid scrolls: the placeholder states have nothing to
246
+ scroll, and `h-full` wouldn't resolve for them inside the viewport's
247
+ `display: table` content wrapper anyway. -->
248
+ <section
249
+ v-if="status === 'loading' || status === 'idle'"
250
+ class="h-64 flex items-center justify-center gap-2 text-sm text-zinc-500 select-none"
251
+ >
252
+ <Icon
253
+ icon="fluent:spinner-ios-20-regular"
254
+ class="animate-spin"
255
+ />
256
+ {{ t("icon-picker-loading") }}
257
+ </section>
258
+
259
+ <section
260
+ v-else-if="status === 'error'"
261
+ class="h-64 flex flex-col items-center justify-center gap-2 text-sm text-zinc-500 select-none"
262
+ >
263
+ <Icon
264
+ icon="fluent:plug-disconnected-20-regular"
265
+ class="text-2xl! text-zinc-400"
266
+ />
267
+ {{ t("icon-picker-error") }}
268
+ <Button
269
+ size="sm"
270
+ @click="ensureStems"
271
+ >
272
+ {{ t("icon-picker-retry") }}
273
+ </Button>
274
+ </section>
275
+
276
+ <section
277
+ v-else-if="!results.length"
278
+ class="h-64 flex flex-col items-center justify-center gap-2 text-sm text-zinc-500 select-none"
279
+ >
280
+ <Icon
281
+ icon="fluent:search-20-regular"
282
+ class="text-2xl! text-zinc-400"
283
+ />
284
+ {{ t("icon-picker-empty") }}
285
+ </section>
286
+
287
+ <ScrollArea.Root
288
+ v-else
289
+ class="relative h-64"
290
+ >
291
+ <ScrollArea.Viewport
292
+ ref="viewport"
293
+ class="size-full overscroll-contain p-2 outline-none"
294
+ >
295
+ <div
296
+ class="relative w-full"
297
+ :style="{ height: `${rowTotalSize}px` }"
298
+ >
299
+ <div
300
+ v-for="row in rowWindow"
301
+ :key="String(row.key)"
302
+ class="absolute top-0 left-0 grid w-full"
303
+ :style="{
304
+ height: `${row.size}px`,
305
+ transform: `translateY(${row.start}px)`,
306
+ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`
307
+ }"
308
+ >
309
+ <button
310
+ v-for="stem in rowStems(row.index)"
311
+ :key="stem"
312
+ type="button"
313
+ :title="`fluent:${stem}${SUFFIX[variant]}`"
314
+ class="flex items-center justify-center rounded text-zinc-600 transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900"
315
+ :class="isSelected(stem) && 'bg-zinc-100 text-(--primary)'"
316
+ @click="select(stem)"
317
+ >
318
+ <Icon
319
+ :icon="`fluent:${stem}${SUFFIX[variant]}`"
320
+ class="text-xl!"
321
+ />
322
+ </button>
323
+ </div>
324
+ </div>
325
+ </ScrollArea.Viewport>
326
+ <ScrollBar orientation="vertical" />
327
+ <ScrollArea.Corner />
328
+ </ScrollArea.Root>
329
+ </Tabs>
330
+ </PopoverContent>
331
+ </Popover>
48
332
  </template>
@@ -1,7 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  modelValue?: string;
3
3
  placeholder?: string;
4
- browseUrl?: string;
5
4
  };
6
5
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
6
  "update:modelValue": (value: string) => any;
@@ -13,6 +13,7 @@ const delegatedProps = reactiveOmit(props, "class");
13
13
  const barEl = ref(null);
14
14
  const barStyle = shallowRef({});
15
15
  const ready = ref(false);
16
+ const animated = ref(false);
16
17
  let mo = null;
17
18
  let ro = null;
18
19
  function measure() {
@@ -29,7 +30,15 @@ function measure() {
29
30
  width: `${rect.width}px`,
30
31
  transform: `translateX(${rect.left - rootRect.left}px)`
31
32
  };
33
+ if (ready.value) return;
32
34
  ready.value = true;
35
+ if (typeof requestAnimationFrame === "undefined") {
36
+ animated.value = true;
37
+ return;
38
+ }
39
+ requestAnimationFrame(() => requestAnimationFrame(() => {
40
+ animated.value = true;
41
+ }));
33
42
  }
34
43
  function schedule() {
35
44
  nextTick(measure);
@@ -79,7 +88,8 @@ onBeforeUnmount(() => {
79
88
  ref="barEl"
80
89
  data-slot="tabs-active-bar"
81
90
  aria-hidden="true"
82
- class="pointer-events-none absolute -bottom-px left-0 z-1 h-0.5 bg-(--primary) transition-[width,transform] duration-300 ease-[cubic-bezier(0.645,0.045,0.355,1)]"
91
+ class="pointer-events-none absolute -bottom-px left-0 z-1 h-0.5 bg-(--primary)"
92
+ :class="animated && 'transition-[width,transform] duration-300 ease-[cubic-bezier(0.645,0.045,0.355,1)]'"
83
93
  :style="{ ...barStyle, visibility: ready ? 'visible' : 'hidden' }"
84
94
  />
85
95
  </TabsList>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shwfed/config",
3
- "version": "3.0.19",
3
+ "version": "3.0.21",
4
4
  "description": "Configurable UI for SHWFED",
5
5
  "type": "module",
6
6
  "publishConfig": {