@saasicat/ui-vue 0.24.2 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/ui-vue",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Vue 3 components, resource clients and composables for the SuperAdmin UI shell. Provides boot and manifest loaders, navigation, actions and standard pages.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -61,7 +61,7 @@
61
61
  "src"
62
62
  ],
63
63
  "dependencies": {
64
- "@saasicat/types": "^0.24.2"
64
+ "@saasicat/types": "^0.25.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "pinia": "^2.0.0 || ^3.0.0",
@@ -0,0 +1,125 @@
1
+ <template>
2
+ <div class="sa-accordion" :class="{ 'sa-accordion--open': open }">
3
+ <div class="sa-accordion__head">
4
+ <button
5
+ :id="triggerId"
6
+ type="button"
7
+ class="sa-accordion__trigger"
8
+ :aria-expanded="open"
9
+ :aria-controls="bodyId"
10
+ :disabled="disabled"
11
+ @click="emit('update:open', !open)"
12
+ >
13
+ <span
14
+ v-if="$slots.mark"
15
+ class="sa-accordion__mark"
16
+ :class="`sa-accordion__mark--${markTone}`"
17
+ >
18
+ <slot name="mark" />
19
+ </span>
20
+ <span class="sa-accordion__label"><slot name="header" /></span>
21
+ <q-icon name="chevron_right" size="18px" class="sa-accordion__chev" />
22
+ </button>
23
+ <div v-if="$slots['header-actions']" class="sa-accordion__actions">
24
+ <slot name="header-actions" />
25
+ </div>
26
+ </div>
27
+ <div
28
+ v-if="open"
29
+ :id="bodyId"
30
+ role="region"
31
+ :aria-labelledby="triggerId"
32
+ class="sa-accordion__body"
33
+ >
34
+ <slot />
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { useId } from 'vue';
41
+
42
+ // One disclosure: a header that opens a body. Eight surfaces in this package
43
+ // built their own, in three incompatible idioms, and agreed on nothing —
44
+ // three header paddings, three body paddings, two radii, two colours for the
45
+ // open border, two transition durations, and hover feedback on exactly one of
46
+ // six despite five setting `cursor: pointer`.
47
+ //
48
+ // The reason is not that eight decisions were taken. It is that none was: there
49
+ // is an `AdminTable` and an `AdminSection`, and for "open this" there was
50
+ // nothing, so every page wrote it again. `admin-page-shell.test.ts` rule 14
51
+ // records the same story for tables — "nine pages used QTable directly and
52
+ // agreed on nothing".
53
+ //
54
+ // **The trigger is a `<button>`, and that is the point of the component.**
55
+ // Four of the eight were a `<div>` with a click handler: no `tabindex`, no
56
+ // keyboard, and a screen reader announcing text rather than a control. A grep
57
+ // for `aria-expanded` across the whole package returned nothing at all. A
58
+ // shared component has that argument once; eight surfaces have to win it eight
59
+ // times, and the score was 4:4.
60
+ //
61
+ // `header-actions` is a SIBLING of the button rather than a slot inside it, and
62
+ // both halves of that matter. A control inside the trigger would fire the
63
+ // toggle on its way past — the discovery cards put a live status control in
64
+ // their clickable header — and interactive content nested in a `<button>` is
65
+ // not valid HTML, so the browser's own behaviour there is not something to
66
+ // rely on.
67
+ //
68
+ // The state is NOT owned here. Five of the eight take it from the page
69
+ // (`openKey === row.id`), and in the bundle list opening one card while closing
70
+ // another is tied to loading that bundle's versions — a component that owned a
71
+ // boolean would break that. The other four simply hold the id, and lose nothing
72
+ // by the arrangement. `v-model:open` keeps the page in charge either way.
73
+ //
74
+ // The body is `v-if`, which is what all seven JS implementations already did.
75
+ // Uniform by accident until now; uniform on purpose from here.
76
+ const {
77
+ open,
78
+ disabled = false,
79
+ markTone = 'accent',
80
+ } = defineProps<{
81
+ /** Whether the body is shown. The page owns this — see above. */
82
+ open: boolean;
83
+ /** A locked row: the trigger is inert and announces itself as such. */
84
+ disabled?: boolean;
85
+ /**
86
+ * The `mark` badge's tone. `accent` unless the row is in a state the badge
87
+ * itself should report — the discovery quota with no usage provider is the
88
+ * one case, and it swaps its glyph as well.
89
+ *
90
+ * Two values because two exist. The frame, size, radius and position do not
91
+ * change with it: that is what makes the badge the same badge everywhere.
92
+ */
93
+ markTone?: 'accent' | 'negative';
94
+ }>();
95
+
96
+ const emit = defineEmits<{ 'update:open': [value: boolean] }>();
97
+
98
+ defineSlots<{
99
+ /**
100
+ * The icon that identifies the row, in a badge this component draws.
101
+ *
102
+ * A slot rather than a prop, because the glyph is sometimes conditional and
103
+ * sometimes carries its own attributes. The BADGE is not the page's
104
+ * business: three surfaces had three answers — a 34px accent-tinted square,
105
+ * a bare 22px glyph, and a bare 20px glyph coloured by a Quasar palette
106
+ * prop — so the same row on two pages did not look like the same row.
107
+ */
108
+ mark?(): unknown;
109
+ /** The header's content — a title, keys, chips, whatever the row needs. */
110
+ header(): unknown;
111
+ /**
112
+ * Controls on the right of the header that must NOT toggle it.
113
+ * Rendered outside the trigger, so a click here is only a click here.
114
+ */
115
+ 'header-actions'?(): unknown;
116
+ /** The body. Only rendered while open. */
117
+ default(): unknown;
118
+ }>();
119
+
120
+ // `useId`, as in `AdminSection`: `aria-controls` and `aria-labelledby` have to
121
+ // point at THIS instance, and an accordion is by nature rendered many times on
122
+ // one page.
123
+ const triggerId = useId();
124
+ const bodyId = useId();
125
+ </script>
@@ -687,32 +687,14 @@ const classifyDiff = computed(() => props.classifyDiff);
687
687
  flex-direction: column;
688
688
  gap: 8px;
689
689
  }
690
- .sa-bd-card {
691
- background: var(--sa-color-bg-surface);
692
- border: 1px solid var(--sa-color-border);
693
- border-radius: 10px;
694
- overflow: hidden;
695
- }
696
- .sa-bd-card.open {
697
- border-color: var(--sa-color-accent);
698
- }
690
+ /* The card's surface, open border, head padding, chevron and body now come
691
+ * from `AdminAccordion` — twelve declarations that were this page's fourth
692
+ * opinion on what an accordion looks like. What is left below is what the
693
+ * BUNDLE row puts inside that header. */
699
694
  .sa-bd-card__head {
700
695
  display: flex;
701
- gap: 12px;
696
+ gap: var(--sa-space-4);
702
697
  align-items: center;
703
- padding: 12px 14px;
704
- cursor: pointer;
705
- }
706
- .sa-bd-card__mark {
707
- width: 34px;
708
- height: 34px;
709
- border-radius: 8px;
710
- background: var(--sa-color-accent-surface-strong);
711
- color: var(--sa-color-accent-strong);
712
- display: flex;
713
- align-items: center;
714
- justify-content: center;
715
- flex-shrink: 0;
716
698
  }
717
699
  .sa-bd-card__titlewrap {
718
700
  flex: 1;
@@ -737,18 +719,6 @@ const classifyDiff = computed(() => props.classifyDiff);
737
719
  font-size: var(--sa-text-sm);
738
720
  color: var(--sa-color-fg-muted);
739
721
  }
740
- .sa-bd-card__chev {
741
- transition: transform 0.15s;
742
- color: var(--sa-color-fg-subtle);
743
- }
744
- .sa-bd-card__chev.open {
745
- transform: rotate(90deg);
746
- }
747
- .sa-bd-card__body {
748
- border-top: 1px solid var(--sa-color-border);
749
- padding: 14px;
750
- background: var(--sa-color-bg-sunken);
751
- }
752
722
  .sa-bd-grid {
753
723
  display: grid;
754
724
  grid-template-columns: minmax(280px, 360px) 1fr;
@@ -1,44 +1,41 @@
1
1
  <template>
2
2
  <div class="sa-bundles__list">
3
- <div
3
+ <AdminAccordion
4
4
  v-for="bundle in filteredBundles"
5
5
  :key="bundle.id"
6
6
  class="sa-bd-card"
7
- :class="{ open: openKey === bundle.id }"
7
+ :open="openKey === bundle.id"
8
+ @update:open="emit('toggle', bundle)"
8
9
  >
9
- <div class="sa-bd-card__head" @click="emit('toggle', bundle)">
10
- <div class="sa-bd-card__mark"><q-icon name="inventory_2" size="18px" /></div>
11
- <div class="sa-bd-card__titlewrap">
12
- <div class="sa-bd-card__titlerow">
13
- <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
14
- <span
15
- class="sa-bundle-chip"
16
- :class="`sa-bundle-chip--${statusClass(bundle)}`"
17
- :title="statusTooltip(bundle)"
18
- >
19
- {{ statusLabel(bundle) }}
20
- </span>
21
- <span
22
- v-if="i18nLocaleCount(bundle) > 0"
23
- class="sa-bundle-chip sa-bundle-chip--info"
24
- >
25
- {{ translationCount(bundle) }}
26
- </span>
10
+ <template #mark><q-icon name="inventory_2" size="18px" /></template>
11
+
12
+ <template #header>
13
+ <div class="sa-bd-card__head">
14
+ <div class="sa-bd-card__titlewrap">
15
+ <div class="sa-bd-card__titlerow">
16
+ <span class="sa-bd-card__key">{{ bundle.bundleKey }}</span>
17
+ <span
18
+ class="sa-bundle-chip"
19
+ :class="`sa-bundle-chip--${statusClass(bundle)}`"
20
+ :title="statusTooltip(bundle)"
21
+ >
22
+ {{ statusLabel(bundle) }}
23
+ </span>
24
+ <span
25
+ v-if="i18nLocaleCount(bundle) > 0"
26
+ class="sa-bundle-chip sa-bundle-chip--info"
27
+ >
28
+ {{ translationCount(bundle) }}
29
+ </span>
30
+ </div>
31
+ <div class="sa-bd-card__name">{{ bundle.label }}</div>
32
+ <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
27
33
  </div>
28
- <div class="sa-bd-card__name">{{ bundle.label }}</div>
29
- <div class="sa-bd-card__desc">{{ bundle.description || '—' }}</div>
30
34
  </div>
31
- <q-icon
32
- name="chevron_right"
33
- class="sa-bd-card__chev"
34
- :class="{ open: openKey === bundle.id }"
35
- />
36
- </div>
35
+ </template>
37
36
 
38
- <div v-if="openKey === bundle.id" class="sa-bd-card__body">
39
- <slot name="detail" :bundle="bundle" />
40
- </div>
41
- </div>
37
+ <slot name="detail" :bundle="bundle" />
38
+ </AdminAccordion>
42
39
 
43
40
  <div v-if="bundlesTotal > 0 && filteredBundles.length === 0" class="sa-bd-empty-row">
44
41
  {{ msg.list.emptyNoMatch }}
@@ -48,6 +45,7 @@
48
45
 
49
46
  <script setup lang="ts">
50
47
  import type { BundleRow } from '@saasicat/types';
48
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
51
49
  import {
52
50
  bundleStatusMeta,
53
51
  type BundleAggregateStatus,
@@ -1,71 +1,79 @@
1
1
  <template>
2
- <div class="sa-fc" :class="{ expanded }">
3
- <div class="sa-fc__head" @click="emit('toggle')">
4
- <q-icon :name="iconValue || 'help_outline'" size="22px" class="sa-fc__icon" />
5
- <div class="sa-fc__main">
6
- <div class="sa-fc__titlerow">
7
- <code class="sa-fc__key">{{ feature.featureKey }}</code>
8
- <span class="sa-fc__label">{{ labelValue || feature.featureKey }}</span>
9
- <span v-if="newCapsCount > 0" class="sa-fc__flag sa-fc__flag--new">
10
- {{ newCapsLabel }}
11
- </span>
12
- <span v-if="deprecatedCapsCount > 0" class="sa-fc__flag sa-fc__flag--dep">
13
- {{ deprecatedCapsLabel }}
14
- </span>
15
- <span
16
- v-if="feature.successorKey"
17
- class="sa-fc__flag sa-fc__flag--succ"
18
- :title="replacedByLabel"
19
- >
20
- {{ replacedByLabel }}
21
- </span>
2
+ <AdminAccordion class="sa-fc" :open="expanded" @update:open="emit('toggle')">
3
+ <template #mark><q-icon :name="iconValue || 'help_outline'" size="18px" /></template>
4
+
5
+ <template #header>
6
+ <div class="sa-fc__head">
7
+ <div class="sa-fc__main">
8
+ <div class="sa-fc__titlerow">
9
+ <code class="sa-fc__key">{{ feature.featureKey }}</code>
10
+ <span class="sa-fc__label">{{ labelValue || feature.featureKey }}</span>
11
+ <span v-if="newCapsCount > 0" class="sa-fc__flag sa-fc__flag--new">
12
+ {{ newCapsLabel }}
13
+ </span>
14
+ <span v-if="deprecatedCapsCount > 0" class="sa-fc__flag sa-fc__flag--dep">
15
+ {{ deprecatedCapsLabel }}
16
+ </span>
17
+ <span
18
+ v-if="feature.successorKey"
19
+ class="sa-fc__flag sa-fc__flag--succ"
20
+ :title="replacedByLabel"
21
+ >
22
+ {{ replacedByLabel }}
23
+ </span>
24
+ <span
25
+ v-if="feature.replaces.length"
26
+ class="sa-fc__flag sa-fc__flag--repl"
27
+ :title="replacesLabel"
28
+ >
29
+ {{ replacesLabel }}
30
+ </span>
31
+ </div>
32
+ <div class="sa-fc__sub">
33
+ <template v-if="owners.length">
34
+ <span class="sa-muted">{{ msg.owner }}</span>
35
+ {{ owners.join(', ') }}
36
+ <span class="sa-fc__dot">·</span>
37
+ </template>
38
+ {{ capabilities.length }}
39
+ {{
40
+ capabilities.length === 1
41
+ ? msg.feature.capabilityOne
42
+ : msg.feature.capabilityMany
43
+ }}
44
+ <template v-if="tierValue">
45
+ <span class="sa-fc__dot">·</span>
46
+ <span class="sa-fc__tier">{{ tierValue }}</span>
47
+ </template>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="sa-fc__coverage">
22
52
  <span
23
- v-if="feature.replaces.length"
24
- class="sa-fc__flag sa-fc__flag--repl"
25
- :title="replacesLabel"
53
+ v-for="lng in targetLocales"
54
+ :key="lng"
55
+ class="sa-cov-pill"
56
+ :class="coverageClass(coverage(lng))"
26
57
  >
27
- {{ replacesLabel }}
58
+ <span>{{ localeShort(lng) }}</span>
59
+ <span>{{ coveragePct(coverage(lng)) }}%</span>
28
60
  </span>
29
61
  </div>
30
- <div class="sa-fc__sub">
31
- <template v-if="owners.length">
32
- <span class="sa-muted">{{ msg.owner }}</span>
33
- {{ owners.join(', ') }}
34
- <span class="sa-fc__dot">·</span>
35
- </template>
36
- {{ capabilities.length }}
37
- {{
38
- capabilities.length === 1
39
- ? msg.feature.capabilityOne
40
- : msg.feature.capabilityMany
41
- }}
42
- <template v-if="tierValue">
43
- <span class="sa-fc__dot">·</span>
44
- <span class="sa-fc__tier">{{ tierValue }}</span>
45
- </template>
46
- </div>
47
- </div>
48
-
49
- <div class="sa-fc__coverage">
50
- <span
51
- v-for="lng in targetLocales"
52
- :key="lng"
53
- class="sa-cov-pill"
54
- :class="coverageClass(coverage(lng))"
55
- >
56
- <span>{{ localeShort(lng) }}</span>
57
- <span>{{ coveragePct(coverage(lng)) }}%</span>
58
- </span>
59
62
  </div>
63
+ </template>
60
64
 
65
+ <!-- Outside the trigger, not merely `@click.stop` inside it. The old
66
+ head was a `<div @click>` with this control nested in it, and the
67
+ only thing keeping a status change from also toggling the card was
68
+ a `.stop` somebody remembered. Structure beats memory. -->
69
+ <template #header-actions>
61
70
  <DiscoveryStatusControl
62
71
  :status="feature.discoveryStatus"
63
72
  @set-status="(target) => emit('review', feature.featureKey, target)"
64
73
  />
65
- <q-icon name="chevron_right" class="sa-fc__chev" :class="{ open: expanded }" />
66
- </div>
74
+ </template>
67
75
 
68
- <div v-if="expanded" class="sa-fc__body">
76
+ <div class="sa-fc__body">
69
77
  <div v-if="feature.discoveryStatus === 'outdated'" class="sa-fc__banner warn">
70
78
  <q-icon name="warning" size="16px" />
71
79
  <span>
@@ -140,7 +148,7 @@
140
148
  "
141
149
  />
142
150
  </div>
143
- </div>
151
+ </AdminAccordion>
144
152
  </template>
145
153
 
146
154
  <script setup lang="ts">
@@ -152,6 +160,7 @@ import type {
152
160
  FeatureCatalogEntryRow,
153
161
  UpdateCatalogEntryBaseData,
154
162
  } from '@saasicat/types';
163
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
155
164
  import CatalogEntryTransPanel from './CatalogEntryTransPanel.vue';
156
165
  import DiscoveryCapList from './DiscoveryCapList.vue';
157
166
  import DiscoveryStatusControl from './DiscoveryStatusControl.vue';
@@ -268,26 +277,16 @@ function coverage(locale: string): number {
268
277
  </script>
269
278
 
270
279
  <style scoped>
271
- .sa-fc {
272
- border: 1px solid var(--sa-color-border);
273
- border-radius: 10px;
274
- background: var(--sa-color-bg-surface);
275
- overflow: hidden;
276
- }
277
- .sa-fc.expanded {
278
- border-color: var(--sa-color-scheduled-border);
279
- box-shadow: 0 1px 3px var(--sa-shadow-tint-2);
280
- }
280
+ /* Surface, radius, open border, head padding, chevron and body come from
281
+ * `AdminAccordion`. What is left is what a FEATURE row puts in that header.
282
+ *
283
+ * These seven rules were byte-identical to `DiscoveryQuotaCard`'s after a
284
+ * prefix rename — jscpd could not see it, because `minTokens: 50` is larger
285
+ * than any single rule here. The duplication was real and below the threshold. */
281
286
  .sa-fc__head {
282
287
  display: flex;
283
288
  align-items: center;
284
- gap: 12px;
285
- padding: 10px 12px;
286
- cursor: pointer;
287
- }
288
- .sa-fc__icon {
289
- color: var(--sa-color-fg-secondary);
290
- flex-shrink: 0;
289
+ gap: var(--sa-space-4);
291
290
  }
292
291
  .sa-fc__main {
293
292
  flex: 1;
@@ -355,18 +354,6 @@ function coverage(locale: string): number {
355
354
  gap: 6px;
356
355
  flex-shrink: 0;
357
356
  }
358
- .sa-fc__chev {
359
- color: var(--sa-color-fg-subtle);
360
- transition: transform 0.15s;
361
- }
362
- .sa-fc__chev.open {
363
- transform: rotate(90deg);
364
- }
365
- .sa-fc__body {
366
- border-top: 1px solid var(--sa-color-border-soft);
367
- padding: 12px;
368
- background: var(--sa-color-bg-sunken);
369
- }
370
357
  .sa-fc__banner {
371
358
  display: flex;
372
359
  align-items: center;
@@ -1,62 +1,76 @@
1
1
  <template>
2
- <div class="sa-qc" :class="{ expanded, warn: !quota.usageProvider }">
3
- <div class="sa-qc__head" @click="emit('toggle')">
4
- <q-icon
5
- :name="quota.usageProvider ? 'inventory_2' : 'error'"
6
- :color="quota.usageProvider ? 'primary' : 'negative'"
7
- size="20px"
8
- class="sa-qc__icon"
9
- />
10
- <div class="sa-qc__main">
11
- <div class="sa-qc__titlerow">
12
- <span class="sa-qc__label">{{ labelValue || quota.quotaKey }}</span>
13
- <code class="sa-qc__key">{{ quota.quotaKey }}</code>
14
- <span class="sa-chip">{{ quota.enforcementMode }}</span>
15
- <span
16
- v-if="quota.successorKey"
17
- class="sa-qc__flag sa-qc__flag--succ"
18
- :title="replacedByLabel"
19
- >
20
- {{ replacedByLabel }}
21
- </span>
2
+ <AdminAccordion
3
+ class="sa-qc"
4
+ :class="{ warn: !quota.usageProvider }"
5
+ :open="expanded"
6
+ :mark-tone="quota.usageProvider ? 'accent' : 'negative'"
7
+ @update:open="emit('toggle')"
8
+ >
9
+ <!-- The glyph already says it; the tone lets the badge say it too. The
10
+ `:color` this replaces was a Quasar PALETTE name, which reaches past
11
+ the role layer — `negative` there is Quasar's, not the theme's. -->
12
+ <template #mark>
13
+ <q-icon :name="quota.usageProvider ? 'inventory_2' : 'error'" size="18px" />
14
+ </template>
15
+
16
+ <template #header>
17
+ <div class="sa-qc__head">
18
+ <div class="sa-qc__main">
19
+ <div class="sa-qc__titlerow">
20
+ <span class="sa-qc__label">{{ labelValue || quota.quotaKey }}</span>
21
+ <code class="sa-qc__key">{{ quota.quotaKey }}</code>
22
+ <span class="sa-chip">{{ quota.enforcementMode }}</span>
23
+ <span
24
+ v-if="quota.successorKey"
25
+ class="sa-qc__flag sa-qc__flag--succ"
26
+ :title="replacedByLabel"
27
+ >
28
+ {{ replacedByLabel }}
29
+ </span>
30
+ <span
31
+ v-if="quota.replaces.length"
32
+ class="sa-qc__flag sa-qc__flag--repl"
33
+ :title="replacesLabel"
34
+ >
35
+ {{ replacesLabel }}
36
+ </span>
37
+ </div>
38
+ <div class="sa-qc__sub">
39
+ {{ msg.unit }} <code>{{ quota.unit }}</code> · {{ msg.quota.usageProvider }}
40
+ <code v-if="quota.usageProvider">{{ quota.usageProvider }}</code>
41
+ <span v-else class="sa-qc__missing">{{
42
+ msg.quota.usageProviderMissing
43
+ }}</span>
44
+ </div>
45
+ <div v-if="!quota.usageProvider" class="sa-qc__warning">
46
+ {{ msg.quota.noUsageProviderWarning }}
47
+ </div>
48
+ </div>
49
+
50
+ <div class="sa-qc__coverage">
22
51
  <span
23
- v-if="quota.replaces.length"
24
- class="sa-qc__flag sa-qc__flag--repl"
25
- :title="replacesLabel"
52
+ v-for="lng in targetLocales"
53
+ :key="lng"
54
+ class="sa-cov-pill"
55
+ :class="coverageClass(coverage(lng))"
26
56
  >
27
- {{ replacesLabel }}
57
+ <span>{{ localeShort(lng) }}</span>
58
+ <span>{{ coveragePct(coverage(lng)) }}%</span>
28
59
  </span>
29
60
  </div>
30
- <div class="sa-qc__sub">
31
- {{ msg.unit }} <code>{{ quota.unit }}</code> · {{ msg.quota.usageProvider }}
32
- <code v-if="quota.usageProvider">{{ quota.usageProvider }}</code>
33
- <span v-else class="sa-qc__missing">{{ msg.quota.usageProviderMissing }}</span>
34
- </div>
35
- <div v-if="!quota.usageProvider" class="sa-qc__warning">
36
- {{ msg.quota.noUsageProviderWarning }}
37
- </div>
38
- </div>
39
-
40
- <div class="sa-qc__coverage">
41
- <span
42
- v-for="lng in targetLocales"
43
- :key="lng"
44
- class="sa-cov-pill"
45
- :class="coverageClass(coverage(lng))"
46
- >
47
- <span>{{ localeShort(lng) }}</span>
48
- <span>{{ coveragePct(coverage(lng)) }}%</span>
49
- </span>
50
61
  </div>
62
+ </template>
51
63
 
64
+ <!-- See DiscoveryFeatureCard: outside the trigger, not `@click.stop`
65
+ inside it. -->
66
+ <template #header-actions>
52
67
  <DiscoveryStatusControl
53
68
  :status="quota.discoveryStatus"
54
69
  @set-status="(target) => emit('review', quota.quotaKey, target)"
55
70
  />
56
- <q-icon name="chevron_right" class="sa-qc__chev" :class="{ open: expanded }" />
57
- </div>
71
+ </template>
58
72
 
59
- <div v-if="expanded" class="sa-qc__body">
73
+ <div class="sa-qc__body">
60
74
  <div v-if="quota.discoveryStatus === 'outdated'" class="sa-qc__banner">
61
75
  <q-icon name="warning" size="16px" />
62
76
  <span>
@@ -74,7 +88,7 @@
74
88
  "
75
89
  />
76
90
  </div>
77
- </div>
91
+ </AdminAccordion>
78
92
  </template>
79
93
 
80
94
  <script setup lang="ts">
@@ -85,6 +99,7 @@ import type {
85
99
  QuotaCatalogEntryRow,
86
100
  UpdateCatalogEntryBaseData,
87
101
  } from '@saasicat/types';
102
+ import AdminAccordion from '../../components/admin-page/AdminAccordion.vue';
88
103
  import CatalogEntryTransPanel from './CatalogEntryTransPanel.vue';
89
104
  import DiscoveryStatusControl from './DiscoveryStatusControl.vue';
90
105
  import { formatMessage } from '../../client/i18n/format.js';
@@ -153,31 +168,22 @@ function coverage(locale: string): number {
153
168
  </script>
154
169
 
155
170
  <style scoped>
156
- .sa-qc {
157
- border: 1px solid var(--sa-color-border);
158
- border-radius: 10px;
159
- background: var(--sa-color-bg-surface);
160
- overflow: hidden;
161
- }
171
+ /* Surface, radius, open border, head padding, chevron and body come from
172
+ * `AdminAccordion`. What is left is what a QUOTA row puts in that header — and
173
+ * `warn`, which is this card's own idea and not the accordion's.
174
+ *
175
+ * Two values changed by moving: the open border was
176
+ * `--sa-color-scheduled-border` plus a shadow here and `--sa-color-accent`
177
+ * everywhere else, and the head padding was 10/12 against 12/14 next door.
178
+ * Those differences were the finding, not the design. */
162
179
  .sa-qc.warn {
163
180
  border-color: var(--sa-color-negative-border);
164
- }
165
- .sa-qc.expanded {
166
- border-color: var(--sa-color-scheduled-border);
167
- box-shadow: 0 1px 3px var(--sa-shadow-tint-2);
181
+ background: var(--sa-color-negative-surface);
168
182
  }
169
183
  .sa-qc__head {
170
184
  display: flex;
171
185
  align-items: center;
172
- gap: 12px;
173
- padding: 10px 12px;
174
- cursor: pointer;
175
- }
176
- .sa-qc.warn .sa-qc__head {
177
- background: var(--sa-color-negative-surface);
178
- }
179
- .sa-qc__icon {
180
- flex-shrink: 0;
186
+ gap: var(--sa-space-4);
181
187
  }
182
188
  .sa-qc__main {
183
189
  flex: 1;
@@ -230,18 +236,6 @@ function coverage(locale: string): number {
230
236
  gap: 6px;
231
237
  flex-shrink: 0;
232
238
  }
233
- .sa-qc__chev {
234
- color: var(--sa-color-fg-subtle);
235
- transition: transform 0.15s;
236
- }
237
- .sa-qc__chev.open {
238
- transform: rotate(90deg);
239
- }
240
- .sa-qc__body {
241
- border-top: 1px solid var(--sa-color-border-soft);
242
- padding: 12px;
243
- background: var(--sa-color-bg-sunken);
244
- }
245
239
  .sa-qc__banner {
246
240
  display: flex;
247
241
  align-items: center;
@@ -0,0 +1,118 @@
1
+ /* Accordion — a header that opens a body (see AdminAccordion).
2
+ *
3
+ * Every value here was one of several. The survey that led to this file found
4
+ * three header paddings across the package (0, 10/12, 12/14), three body
5
+ * paddings (12, 14, 18/20/22), two radii (8 and 10), two colours for the open
6
+ * border (`accent` and `scheduled-border`), two transition durations, and hover
7
+ * feedback on one of six surfaces that all set `cursor: pointer`.
8
+ *
9
+ * The values below are the scale's, not the average of the six: a rung exists
10
+ * for each, so a literal here would be a fourth opinion rather than a decision.
11
+ */
12
+
13
+ .sa-accordion {
14
+ background: var(--sa-color-bg-surface);
15
+ border: 1px solid var(--sa-color-border);
16
+ border-radius: var(--sa-radius-head);
17
+ overflow: hidden;
18
+ }
19
+
20
+ /* The open card names itself with the accent rather than with a tone: which
21
+ * row is open is a position, not a status, and a status colour here would
22
+ * compete with the chips the header carries. */
23
+ .sa-accordion--open {
24
+ border-color: var(--sa-color-accent);
25
+ }
26
+
27
+ .sa-accordion__head {
28
+ display: flex;
29
+ align-items: stretch;
30
+ }
31
+
32
+ /* The trigger fills the head so the whole row is the target — the affordance
33
+ * the `<div @click>` versions had, without giving up the button. */
34
+ .sa-accordion__trigger {
35
+ flex: 1;
36
+ min-width: 0;
37
+ display: flex;
38
+ align-items: center;
39
+ gap: var(--sa-space-4);
40
+ padding: var(--sa-space-4) var(--sa-space-5);
41
+ background: none;
42
+ border: 0;
43
+ font: inherit;
44
+ color: inherit;
45
+ text-align: left;
46
+ cursor: pointer;
47
+ }
48
+
49
+ /* Hover feedback, which five of the six surfaces promised with `cursor` and
50
+ * only one delivered. */
51
+ .sa-accordion__trigger:hover:not(:disabled) {
52
+ background: var(--sa-color-bg-sunken);
53
+ }
54
+ .sa-accordion__trigger:disabled {
55
+ cursor: not-allowed;
56
+ }
57
+
58
+ /* The badge that identifies the row.
59
+ *
60
+ * Three surfaces drew this three ways — a 34px accent-tinted square, a bare
61
+ * 22px glyph, and a bare 20px glyph coloured through a Quasar palette prop — so
62
+ * the same kind of row did not look like the same kind of row from one page to
63
+ * the next. The frame is the component's; only the glyph is the page's.
64
+ *
65
+ * Square-ish rather than round: a circle here would read as an avatar, and none
66
+ * of these rows is about a person. */
67
+ .sa-accordion__mark {
68
+ flex: 0 0 auto;
69
+ width: 34px;
70
+ height: 34px;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ border-radius: var(--sa-radius-field);
75
+ background: var(--sa-color-accent-surface-strong);
76
+ color: var(--sa-color-accent-strong);
77
+ }
78
+
79
+ /* Only for a row whose state the badge itself should report. The size, radius
80
+ * and position do not move with it — that is what keeps it the same badge. */
81
+ .sa-accordion__mark--negative {
82
+ background: var(--sa-color-negative-surface-strong);
83
+ color: var(--sa-color-negative-fg);
84
+ }
85
+
86
+ .sa-accordion__label {
87
+ flex: 1;
88
+ min-width: 0;
89
+ }
90
+
91
+ /* Points right when closed, down when open. Not `transform: rotate(90deg)` on a
92
+ * `chevron_down`: the resting state is the one a reader sees most, and a glyph
93
+ * that means "there is more this way" is the honest one to rest on. */
94
+ .sa-accordion__chev {
95
+ flex: 0 0 auto;
96
+ color: var(--sa-color-fg-subtle);
97
+ transition: transform 0.15s;
98
+ }
99
+ .sa-accordion--open .sa-accordion__chev {
100
+ transform: rotate(90deg);
101
+ }
102
+
103
+ /* Outside the trigger, so a control here does not toggle the row. Padded on
104
+ * its own rather than by the trigger, or the two would share a hover surface
105
+ * and the row would look pressed while the pointer is on a button that does
106
+ * something else. */
107
+ .sa-accordion__actions {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: var(--sa-space-3);
111
+ padding-right: var(--sa-space-5);
112
+ }
113
+
114
+ .sa-accordion__body {
115
+ padding: var(--sa-space-5);
116
+ border-top: 1px solid var(--sa-color-border);
117
+ background: var(--sa-color-bg-sunken);
118
+ }
@@ -40,3 +40,4 @@
40
40
  @import './components/card.css';
41
41
  @import './components/paginator.css';
42
42
  @import './components/table.css';
43
+ @import './components/accordion.css';