Skip to content

Commit eacc686

Browse files
authored
feat(extension-manager): add Card/CardGrid primitives and test:ct setup (#389)
1 parent 21bf154 commit eacc686

31 files changed

Lines changed: 2316 additions & 7 deletions

.github/workflows/pr-ci-e2e-test.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,19 @@ jobs:
4747
cd packages/test
4848
npx playwright install --with-deps chromium
4949
50-
- name: Run Playwright Tests
51-
run: pnpm test
50+
- name: Run Playwright E2E Tests
51+
run: pnpm -F tiny-robot-test test:e2e
52+
53+
- name: Run Playwright Component Tests
54+
if: ${{ !cancelled() }}
55+
run: pnpm -F tiny-robot-test test:ct
5256

5357
- name: Upload Playwright Test Report
5458
uses: actions/upload-artifact@v6
5559
if: always()
5660
with:
5761
name: playwright-report-${{ github.sha }}
58-
path: packages/test/playwright-report/
62+
path: |
63+
packages/test/playwright-report/
64+
packages/test/component/playwright-report/
5965
retention-days: 13

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ test-results
3737

3838
playwright-report
3939
blob-report
40-
playwright/.cache
40+
**/playwright/.cache/
41+
**/playwright/.cache-development-mode/
4142
package-lock.json
4243
pnpm-lock.yaml
4344
yarn.lock
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
<script setup lang="ts">
2+
import { computed, useSlots, watch } from 'vue'
3+
import type {
4+
ExtensionCardActionEvent,
5+
ExtensionCardEmits,
6+
ExtensionCardProps,
7+
ExtensionCardSlots,
8+
} from '../index.type'
9+
import ExtensionCardMoreMenu from './ExtensionCardMoreMenu.vue'
10+
import ExtensionCardActions from './ExtensionCardActions.vue'
11+
12+
const props = withDefaults(defineProps<ExtensionCardProps>(), {
13+
actions: () => [],
14+
primaryActionsLimit: 1,
15+
nameClickable: true,
16+
overflowMenuLabel: '更多操作',
17+
overflowMenuPlacement: 'bottom-end',
18+
overflowMenuShowIcons: true,
19+
})
20+
21+
const slots = useSlots()
22+
defineSlots<ExtensionCardSlots>()
23+
24+
const emit = defineEmits<ExtensionCardEmits>()
25+
26+
const visibleActions = computed(() => props.actions.filter((action) => !action.hidden))
27+
28+
const normalizedPrimaryActionsLimit = computed(() => Math.max(0, Math.floor(props.primaryActionsLimit)))
29+
30+
const primaryActions = computed(() => visibleActions.value.slice(0, normalizedPrimaryActionsLimit.value))
31+
32+
const overflowActions = computed(() => visibleActions.value.slice(normalizedPrimaryActionsLimit.value))
33+
34+
const shouldShowActions = computed(() => primaryActions.value.length > 0 || overflowActions.value.length > 0)
35+
36+
const normalizedProgress = computed(() => {
37+
if (props.progress === 'indeterminate' || props.progress === undefined) return props.progress
38+
return Math.min(100, Math.max(0, props.progress))
39+
})
40+
41+
const hasPrimaryActionSlot = computed(() => Boolean(slots['primary-action']))
42+
43+
if (import.meta.env.DEV) {
44+
let lastDuplicateIdSet: string | undefined
45+
46+
const getDuplicateIdSet = () => {
47+
const seenIds = new Set<string>()
48+
const duplicateIds = new Set<string>()
49+
50+
for (const action of props.actions) {
51+
if (seenIds.has(action.id)) duplicateIds.add(action.id)
52+
seenIds.add(action.id)
53+
}
54+
55+
const duplicateIdList = [...duplicateIds].sort()
56+
57+
return duplicateIdList.length ? duplicateIdList.join('\u0000') : undefined
58+
}
59+
60+
watch(
61+
getDuplicateIdSet,
62+
(duplicateIdSet) => {
63+
if (duplicateIdSet === lastDuplicateIdSet) return
64+
65+
lastDuplicateIdSet = duplicateIdSet
66+
if (duplicateIdSet === undefined) return
67+
68+
console.warn('[ExtensionManager.Card] Action ids must be unique:', duplicateIdSet.split('\u0000'))
69+
},
70+
{ immediate: true },
71+
)
72+
}
73+
74+
const handleOverflowAction = (action: ExtensionCardActionEvent) => {
75+
emit('action', action)
76+
}
77+
78+
const handleNameClick = (event: MouseEvent) => {
79+
if (!props.nameClickable) return
80+
emit('name-click', event)
81+
}
82+
83+
const handleNameKeydown = (event: KeyboardEvent) => {
84+
if (!props.nameClickable || (event.key !== 'Enter' && event.key !== ' ')) return
85+
event.preventDefault()
86+
emit('name-click', event)
87+
}
88+
</script>
89+
90+
<template>
91+
<div class="tr-extension-card">
92+
<div class="tr-extension-card__icon-region">
93+
<img v-if="typeof icon === 'string' && icon" :src="icon" :alt="name" class="tr-extension-card__icon" />
94+
<component v-else-if="icon" :is="icon" class="tr-extension-card__icon" />
95+
<div v-else class="tr-extension-card__icon tr-extension-card__icon--placeholder">
96+
{{ name.slice(0, 1) }}
97+
</div>
98+
</div>
99+
100+
<div class="tr-extension-card__content">
101+
<div
102+
class="tr-extension-card__name"
103+
:class="{ 'is-clickable': nameClickable }"
104+
:role="nameClickable ? 'button' : undefined"
105+
:tabindex="nameClickable ? 0 : undefined"
106+
:title="name"
107+
@click="handleNameClick"
108+
@keydown="handleNameKeydown"
109+
>
110+
{{ name }}
111+
</div>
112+
<div v-if="description" class="tr-extension-card__description" :title="description">
113+
{{ description }}
114+
</div>
115+
</div>
116+
117+
<div v-if="shouldShowActions" class="tr-extension-card__actions" @click.stop @keydown.stop>
118+
<ExtensionCardActions v-if="primaryActions.length" :actions="primaryActions" @action="emit('action', $event)">
119+
<template v-if="hasPrimaryActionSlot" #primary-action="slotProps">
120+
<slot name="primary-action" v-bind="slotProps" />
121+
</template>
122+
</ExtensionCardActions>
123+
124+
<ExtensionCardMoreMenu
125+
v-if="overflowActions.length"
126+
:actions="overflowActions"
127+
:label="overflowMenuLabel"
128+
:placement="overflowMenuPlacement"
129+
:show-icons="overflowMenuShowIcons"
130+
@action="handleOverflowAction"
131+
/>
132+
</div>
133+
134+
<div
135+
v-if="normalizedProgress !== undefined"
136+
class="tr-extension-card__progress"
137+
role="progressbar"
138+
:aria-label="name"
139+
aria-valuemin="0"
140+
aria-valuemax="100"
141+
:aria-valuenow="normalizedProgress === 'indeterminate' ? undefined : normalizedProgress"
142+
>
143+
<span
144+
class="tr-extension-card__progress-bar"
145+
:class="{ 'is-indeterminate': normalizedProgress === 'indeterminate' }"
146+
:style="normalizedProgress === 'indeterminate' ? undefined : { width: `${normalizedProgress}%` }"
147+
></span>
148+
</div>
149+
</div>
150+
</template>
151+
152+
<style lang="less">
153+
:root {
154+
--tr-extension-card-bg-color: #f8f8f8;
155+
--tr-extension-card-bg-color-hover: rgba(0, 0, 0, 0.04);
156+
--tr-extension-card-focus-color: #191919;
157+
--tr-extension-card-icon-color: #808080;
158+
--tr-extension-card-switch-bg-color: var(--tr-text-disabled);
159+
--tr-extension-card-switch-bg-color-checked: var(--tr-color-primary);
160+
}
161+
</style>
162+
163+
<style lang="less" scoped>
164+
.tr-extension-card {
165+
--tr-extension-card-action-icon-size: 16px;
166+
--tr-extension-card-menu-icon-slot-size: var(--tr-extension-card-action-icon-size);
167+
--tr-extension-card-progress-bg-color: var(--tr-extension-card-bg-color-hover);
168+
--tr-extension-card-progress-bar-color: var(--tr-color-success);
169+
170+
box-sizing: border-box;
171+
position: relative;
172+
display: flex;
173+
align-items: center;
174+
gap: 14px;
175+
min-width: 0;
176+
min-height: 86px;
177+
padding: 14px 20px;
178+
border-radius: 8px;
179+
background: var(--tr-extension-card-bg-color);
180+
overflow: hidden;
181+
}
182+
183+
.tr-extension-card__icon-region {
184+
display: flex;
185+
flex: 0 0 auto;
186+
align-items: center;
187+
}
188+
189+
.tr-extension-card__icon {
190+
display: inline-flex;
191+
align-items: center;
192+
justify-content: center;
193+
width: 40px;
194+
height: 40px;
195+
border-radius: 8px;
196+
object-fit: cover;
197+
overflow: hidden;
198+
199+
&--placeholder {
200+
background: var(--tr-extension-card-bg-color-hover);
201+
color: var(--tr-text-secondary);
202+
font-size: 18px;
203+
font-weight: 600;
204+
cursor: default;
205+
}
206+
}
207+
208+
.tr-extension-card__content {
209+
display: flex;
210+
flex: 1;
211+
flex-direction: column;
212+
justify-content: center;
213+
gap: 4px;
214+
min-width: 0;
215+
}
216+
217+
.tr-extension-card__name {
218+
align-self: flex-start;
219+
max-width: 100%;
220+
overflow: hidden;
221+
color: var(--tr-text-primary);
222+
font-size: 14px;
223+
font-weight: 600;
224+
line-height: 24px;
225+
outline: none;
226+
text-overflow: ellipsis;
227+
white-space: nowrap;
228+
cursor: default;
229+
230+
&.is-clickable {
231+
&:hover {
232+
text-decoration: underline;
233+
}
234+
235+
&:focus-visible {
236+
border-radius: 4px;
237+
box-shadow: 0 0 0 2px var(--tr-extension-card-focus-color);
238+
}
239+
}
240+
}
241+
242+
.tr-extension-card__description {
243+
overflow: hidden;
244+
color: var(--tr-text-secondary);
245+
font-size: 12px;
246+
line-height: 18px;
247+
text-overflow: ellipsis;
248+
white-space: nowrap;
249+
}
250+
251+
.tr-extension-card__actions {
252+
display: flex;
253+
align-items: center;
254+
gap: 10px;
255+
flex-shrink: 0;
256+
}
257+
258+
.tr-extension-card__progress {
259+
position: absolute;
260+
right: 20px;
261+
bottom: 8px;
262+
left: 74px;
263+
height: 4px;
264+
overflow: hidden;
265+
border-radius: 999px;
266+
background: var(--tr-extension-card-progress-bg-color);
267+
}
268+
269+
.tr-extension-card__progress-bar {
270+
display: block;
271+
height: 100%;
272+
border-radius: inherit;
273+
background: var(--tr-extension-card-progress-bar-color);
274+
transition: width 240ms ease-out;
275+
276+
&.is-indeterminate {
277+
width: 40%;
278+
animation: tr-extension-card-progress-indeterminate 1.2s ease-in-out infinite;
279+
}
280+
}
281+
282+
@keyframes tr-extension-card-progress-indeterminate {
283+
0% {
284+
transform: translateX(-120%);
285+
}
286+
287+
100% {
288+
transform: translateX(260%);
289+
}
290+
}
291+
292+
@media (prefers-reduced-motion: reduce) {
293+
.tr-extension-card__progress-bar {
294+
transition: none;
295+
296+
&.is-indeterminate {
297+
animation: none;
298+
}
299+
}
300+
}
301+
</style>

0 commit comments

Comments
 (0)