@antfu/design 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 +21 -0
- package/README.md +165 -0
- package/a11y/cli.ts +73 -0
- package/a11y/index.ts +13 -0
- package/a11y/scan.ts +127 -0
- package/components/Action/ActionButton.stories.ts +56 -0
- package/components/Action/ActionButton.vue +57 -0
- package/components/Action/ActionDarkToggle.stories.ts +31 -0
- package/components/Action/ActionDarkToggle.vue +87 -0
- package/components/Action/ActionIconButton.stories.ts +47 -0
- package/components/Action/ActionIconButton.vue +47 -0
- package/components/Display/DisplayAvatar.stories.ts +36 -0
- package/components/Display/DisplayAvatar.vue +58 -0
- package/components/Display/DisplayBadge.stories.ts +31 -0
- package/components/Display/DisplayBadge.vue +98 -0
- package/components/Display/DisplayBytes.stories.ts +28 -0
- package/components/Display/DisplayBytes.vue +30 -0
- package/components/Display/DisplayDate.stories.ts +37 -0
- package/components/Display/DisplayDate.vue +29 -0
- package/components/Display/DisplayDonut.stories.ts +26 -0
- package/components/Display/DisplayDonut.vue +46 -0
- package/components/Display/DisplayDuration.stories.ts +28 -0
- package/components/Display/DisplayDuration.vue +28 -0
- package/components/Display/DisplayFileIcon.stories.ts +27 -0
- package/components/Display/DisplayFileIcon.vue +30 -0
- package/components/Display/DisplayFilePath.stories.ts +30 -0
- package/components/Display/DisplayFilePath.vue +61 -0
- package/components/Display/DisplayKbd.stories.ts +26 -0
- package/components/Display/DisplayKbd.vue +27 -0
- package/components/Display/DisplayKeyValue.stories.ts +56 -0
- package/components/Display/DisplayKeyValue.vue +51 -0
- package/components/Display/DisplayLabel.stories.ts +27 -0
- package/components/Display/DisplayLabel.vue +33 -0
- package/components/Display/DisplayNumber.stories.ts +27 -0
- package/components/Display/DisplayNumber.vue +24 -0
- package/components/Display/DisplayNumberBadge.stories.ts +26 -0
- package/components/Display/DisplayNumberBadge.vue +22 -0
- package/components/Display/DisplayPackageName.stories.ts +26 -0
- package/components/Display/DisplayPackageName.vue +49 -0
- package/components/Display/DisplayProgressBar.stories.ts +29 -0
- package/components/Display/DisplayProgressBar.vue +90 -0
- package/components/Display/DisplayProportionBar.stories.ts +40 -0
- package/components/Display/DisplayProportionBar.vue +43 -0
- package/components/Display/DisplaySafeImage.stories.ts +43 -0
- package/components/Display/DisplaySafeImage.vue +30 -0
- package/components/Display/DisplayStatusPill.stories.ts +34 -0
- package/components/Display/DisplayStatusPill.vue +42 -0
- package/components/Display/DisplayTree.stories.ts +76 -0
- package/components/Display/DisplayTree.vue +102 -0
- package/components/Display/DisplayVersion.stories.ts +25 -0
- package/components/Display/DisplayVersion.vue +21 -0
- package/components/Feedback/FeedbackEmptyState.stories.ts +38 -0
- package/components/Feedback/FeedbackEmptyState.vue +21 -0
- package/components/Feedback/FeedbackLoading.stories.ts +23 -0
- package/components/Feedback/FeedbackLoading.vue +21 -0
- package/components/Feedback/FeedbackSpinner.stories.ts +25 -0
- package/components/Feedback/FeedbackSpinner.vue +22 -0
- package/components/Feedback/FeedbackTip.stories.ts +34 -0
- package/components/Feedback/FeedbackTip.vue +29 -0
- package/components/Feedback/FeedbackToasts.stories.ts +40 -0
- package/components/Feedback/FeedbackToasts.vue +105 -0
- package/components/Form/FormCheckbox.stories.ts +36 -0
- package/components/Form/FormCheckbox.vue +30 -0
- package/components/Form/FormCombobox.stories.ts +35 -0
- package/components/Form/FormCombobox.vue +83 -0
- package/components/Form/FormField.stories.ts +56 -0
- package/components/Form/FormField.vue +36 -0
- package/components/Form/FormNumberInput.stories.ts +47 -0
- package/components/Form/FormNumberInput.vue +85 -0
- package/components/Form/FormRadioGroup.stories.ts +47 -0
- package/components/Form/FormRadioGroup.vue +43 -0
- package/components/Form/FormSearchField.stories.ts +22 -0
- package/components/Form/FormSearchField.vue +32 -0
- package/components/Form/FormSelect.stories.ts +47 -0
- package/components/Form/FormSelect.vue +56 -0
- package/components/Form/FormSwitch.stories.ts +36 -0
- package/components/Form/FormSwitch.vue +26 -0
- package/components/Form/FormTextInput.stories.ts +39 -0
- package/components/Form/FormTextInput.vue +51 -0
- package/components/Form/FormTextarea.stories.ts +47 -0
- package/components/Form/FormTextarea.vue +32 -0
- package/components/Layout/LayoutBreadcrumb.stories.ts +54 -0
- package/components/Layout/LayoutBreadcrumb.vue +54 -0
- package/components/Layout/LayoutCard.stories.ts +31 -0
- package/components/Layout/LayoutCard.vue +21 -0
- package/components/Layout/LayoutDataTable.stories.ts +77 -0
- package/components/Layout/LayoutDataTable.vue +145 -0
- package/components/Layout/LayoutExpandableList.stories.ts +28 -0
- package/components/Layout/LayoutExpandableList.vue +94 -0
- package/components/Layout/LayoutPanelGrids.stories.ts +28 -0
- package/components/Layout/LayoutPanelGrids.vue +26 -0
- package/components/Layout/LayoutSectionBlock.stories.ts +37 -0
- package/components/Layout/LayoutSectionBlock.vue +37 -0
- package/components/Layout/LayoutSideNav.stories.ts +33 -0
- package/components/Layout/LayoutSideNav.vue +48 -0
- package/components/Layout/LayoutSplitPane.stories.ts +44 -0
- package/components/Layout/LayoutSplitPane.vue +30 -0
- package/components/Layout/LayoutTabs.stories.ts +43 -0
- package/components/Layout/LayoutTabs.vue +56 -0
- package/components/Layout/LayoutToolbar.stories.ts +60 -0
- package/components/Layout/LayoutToolbar.vue +28 -0
- package/components/Layout/LayoutVirtualList.stories.ts +30 -0
- package/components/Layout/LayoutVirtualList.vue +82 -0
- package/components/Overlay/OverlayDrawer.stories.ts +47 -0
- package/components/Overlay/OverlayDrawer.vue +58 -0
- package/components/Overlay/OverlayDropdown.stories.ts +25 -0
- package/components/Overlay/OverlayDropdown.vue +30 -0
- package/components/Overlay/OverlayDropdownItem.stories.ts +26 -0
- package/components/Overlay/OverlayDropdownItem.vue +31 -0
- package/components/Overlay/OverlayDropdownLabel.vue +9 -0
- package/components/Overlay/OverlayDropdownSeparator.vue +7 -0
- package/components/Overlay/OverlayModal.stories.ts +33 -0
- package/components/Overlay/OverlayModal.vue +48 -0
- package/components/Overlay/OverlayTooltip.stories.ts +33 -0
- package/components/Overlay/OverlayTooltip.vue +38 -0
- package/composables/colorScheme.ts +58 -0
- package/composables/toast.ts +81 -0
- package/package.json +99 -0
- package/skills/antfu-design/SKILL.md +65 -0
- package/skills/antfu-design/references/advanced-patterns.md +39 -0
- package/skills/antfu-design/references/best-practices.md +54 -0
- package/skills/antfu-design/references/core-components.md +72 -0
- package/skills/antfu-design/references/core-setup.md +56 -0
- package/skills/antfu-design/references/core-tokens.md +100 -0
- package/skills/antfu-design/references/features-data-presentation.md +27 -0
- package/splitpanes.d.ts +70 -0
- package/styles/animations.css +47 -0
- package/styles/base.css +31 -0
- package/styles/floating-vue.css +28 -0
- package/styles/index.css +7 -0
- package/styles/reka-ui.css +112 -0
- package/styles/scrollbar.css +24 -0
- package/styles/splitpanes.css +61 -0
- package/unocss/colors.ts +127 -0
- package/unocss/index.ts +99 -0
- package/unocss/options.ts +31 -0
- package/unocss/patterns.ts +38 -0
- package/unocss/rules.ts +26 -0
- package/unocss/severity.ts +16 -0
- package/unocss/shortcuts.ts +68 -0
- package/utils/color.ts +328 -0
- package/utils/contrast.ts +118 -0
- package/utils/format.ts +389 -0
- package/utils/icon.ts +200 -0
- package/utils/index.ts +13 -0
- package/utils/keybinding.ts +199 -0
- package/utils/misc.ts +141 -0
- package/utils/path.ts +243 -0
- package/utils/semver.ts +147 -0
- package/utils/tree.ts +89 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anthony Fu <https://github.com/antfu>
|
|
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,165 @@
|
|
|
1
|
+
# @antfu/design
|
|
2
|
+
|
|
3
|
+
> A customizable, **composable** design system for devtools-style Vue apps: a
|
|
4
|
+
> UnoCSS preset (`presetAnthonyDesign`), a set of Vue primitives, a ground-up
|
|
5
|
+
> design skill, and a color-contrast a11y check. Something in between a component
|
|
6
|
+
> library and shadcn.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @antfu/design unocss vue
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
// uno.config.ts
|
|
18
|
+
import { presetAnthonyDesign } from '@antfu/design/unocss'
|
|
19
|
+
import { defineConfig, presetIcons, presetWebFonts, presetWind4 } from 'unocss'
|
|
20
|
+
|
|
21
|
+
export default defineConfig({
|
|
22
|
+
presets: [
|
|
23
|
+
presetAnthonyDesign({ primary: '#49833E' }),
|
|
24
|
+
presetWind4(), // a base preset is required — bring your own
|
|
25
|
+
presetIcons(),
|
|
26
|
+
presetWebFonts({ fonts: { sans: 'DM Sans', mono: 'DM Mono' } }),
|
|
27
|
+
],
|
|
28
|
+
content: { pipeline: { include: [/@antfu\/design/] } },
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// Components are imported by full path (no barrel) — categorized and prefixed:
|
|
34
|
+
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
|
|
35
|
+
import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue'
|
|
36
|
+
import OverlayModal from '@antfu/design/components/Overlay/OverlayModal.vue'
|
|
37
|
+
import '@antfu/design/styles.css'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The package ships **raw `.ts` / `.vue` source** (no bundling) — your build
|
|
41
|
+
compiles it. Point UnoCSS at the package so the components' classes are
|
|
42
|
+
generated (`content: { pipeline: { include: [/@antfu\/design/] } }`).
|
|
43
|
+
|
|
44
|
+
It's a **single** preset that is **not self-contained**: it contributes only the
|
|
45
|
+
antfu design layer (theme tokens, semantic shortcuts, dynamic rules, severity).
|
|
46
|
+
You compose the base preset, icons, fonts and reset yourself.
|
|
47
|
+
|
|
48
|
+
## Exports
|
|
49
|
+
|
|
50
|
+
| Subpath | What |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `./components/*` | one readable `.vue` per component (e.g. `./components/Display/DisplayBadge.vue`) |
|
|
53
|
+
| `./unocss` | the single `presetAnthonyDesign` preset |
|
|
54
|
+
| `./utils` | color, format, path, semver, contrast, keybinding helpers (pure, stateless) |
|
|
55
|
+
| `./composables/*` | Vue helpers: `colorScheme` (opt-in scheme context) and `toast` (`useToast` queue) |
|
|
56
|
+
| `./a11y` | programmatic color-contrast scan |
|
|
57
|
+
| `./styles.css`, `./styles/*` | all styles, or per-concern files |
|
|
58
|
+
| `./splitpanes.d.ts` | opt-in fallback types for older `splitpanes` (v4.1.2+ ships its own) |
|
|
59
|
+
|
|
60
|
+
> The package is **stateless** — no dark-mode/clipboard/toast state. Components
|
|
61
|
+
> that vary by scheme take a `colorScheme` prop; toasts are controlled. Use VueUse
|
|
62
|
+
> directly for state.
|
|
63
|
+
>
|
|
64
|
+
> Two **opt-in** helpers reduce the boilerplate without adding global state:
|
|
65
|
+
> `provideColorScheme(() => isDark ? 'dark' : 'light')` (from `@antfu/design/composables/colorScheme`)
|
|
66
|
+
> lets scheme-aware components inherit the scheme instead of threading a prop, and
|
|
67
|
+
> `useToast()` (from `@antfu/design/composables/toast`) owns a toast queue for
|
|
68
|
+
> `FeedbackToasts`. Both only read state you own.
|
|
69
|
+
|
|
70
|
+
## Accessibility
|
|
71
|
+
|
|
72
|
+
A color-contrast scan (axe-core + Playwright) runs a URL in light **and** dark
|
|
73
|
+
mode. Use it programmatically:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { formatContrastReport, runContrastScan } from '@antfu/design/a11y'
|
|
77
|
+
|
|
78
|
+
const result = await runContrastScan({ url: 'http://localhost:6006/iframe.html' })
|
|
79
|
+
console.log(formatContrastReport(result))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
…or run the bundled script with `tsx`:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
tsx node_modules/@antfu/design/a11y/cli.ts http://localhost:6006/iframe.html
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Tokens
|
|
89
|
+
|
|
90
|
+
<!-- TOKENS:START -->
|
|
91
|
+
### Semantic & composite shortcuts
|
|
92
|
+
|
|
93
|
+
| Token | Expands to |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `color-base` | `color-neutral-800 dark:color-neutral-200` |
|
|
96
|
+
| `color-muted` | `color-neutral-600 dark:color-neutral-400` |
|
|
97
|
+
| `color-faint` | `color-neutral-500 dark:color-neutral-500` |
|
|
98
|
+
| `color-active` | `color-primary-600 dark:color-primary-300` |
|
|
99
|
+
| `bg-base` | `bg-white dark:bg-#111` |
|
|
100
|
+
| `bg-secondary` | `bg-#f5f5f5 dark:bg-#1a1a1a` |
|
|
101
|
+
| `bg-active` | `bg-#8881` |
|
|
102
|
+
| `bg-hover` | `bg-primary/5` |
|
|
103
|
+
| `bg-code` | `bg-gray-500/5` |
|
|
104
|
+
| `bg-tooltip` | `bg-white/75 dark:bg-#111/75 backdrop-blur-8` |
|
|
105
|
+
| `bg-gradient-more` | `bg-gradient-to-t from-white via-white/80 to-white/0 dark:from-#111 dark:via-#111/80 dark:to-#111/0` |
|
|
106
|
+
| `border-base` | `border-#8882` |
|
|
107
|
+
| `border-mute` | `border-#8881` |
|
|
108
|
+
| `border-active` | `border-primary-600/25 dark:border-primary-400/25` |
|
|
109
|
+
| `ring-base` | `ring-#8882` |
|
|
110
|
+
| `op-fade` | `op65 dark:op55` |
|
|
111
|
+
| `op-mute` | `op30 dark:op25` |
|
|
112
|
+
| `btn-action` | `border border-base rounded flex gap-2 items-center px2 py1 op75 hover:op100 hover:bg-active transition disabled:pointer-events-none disabled:op30! outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40` |
|
|
113
|
+
| `btn-action-sm` | `btn-action text-sm` |
|
|
114
|
+
| `btn-action-active` | `color-active border-active! bg-active op100!` |
|
|
115
|
+
| `btn-icon` | `w-9 h-9 rounded-full op-fade hover:op100 hover:bg-active transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40` |
|
|
116
|
+
| `btn-icon-compact` | `w-6 h-6 rounded op-fade hover:op100 hover:bg-active transition flex items-center justify-center disabled:pointer-events-none disabled:op30 outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40` |
|
|
117
|
+
| `btn-primary` | `px3 py1.5 rounded flex gap-2 items-center bg-primary-500 hover:bg-primary-600 text-white transition disabled:op50 disabled:pointer-events-none outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40` |
|
|
118
|
+
| `badge` | `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-xs font-medium leading-none` |
|
|
119
|
+
| `badge-active` | `badge bg-active color-active` |
|
|
120
|
+
| `badge-muted` | `badge bg-#8881 color-muted` |
|
|
121
|
+
|
|
122
|
+
### Severity scale
|
|
123
|
+
|
|
124
|
+
| Token | Expands to |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `color-scale-neutral` | `text-gray-700 dark:text-gray-300` |
|
|
127
|
+
| `color-scale-low` | `text-lime-700 dark:text-lime-300 dark:saturate-75` |
|
|
128
|
+
| `color-scale-medium` | `text-amber-700 dark:text-amber-300 dark:saturate-90` |
|
|
129
|
+
| `color-scale-high` | `text-orange-700 dark:text-orange-300` |
|
|
130
|
+
| `color-scale-critical` | `text-red-700 dark:text-red-300` |
|
|
131
|
+
|
|
132
|
+
### Type sizes
|
|
133
|
+
|
|
134
|
+
| Token | Expands to |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `text-micro` | `text-[10px] leading-[1.4]` |
|
|
137
|
+
| `text-mini` | `text-[11px] leading-[1.45]` |
|
|
138
|
+
| `text-compact` | `text-[12px] leading-[1.5]` |
|
|
139
|
+
|
|
140
|
+
### Named z-index layers
|
|
141
|
+
|
|
142
|
+
| Token | Expands to |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `z-nav` | `z-[30]` |
|
|
145
|
+
| `z-dropdown` | `z-[40]` |
|
|
146
|
+
| `z-tooltip` | `z-[45]` |
|
|
147
|
+
| `z-toast` | `z-[50]` |
|
|
148
|
+
| `z-modal-backdrop` | `z-[60]` |
|
|
149
|
+
| `z-modal-content` | `z-[70]` |
|
|
150
|
+
| `z-drawer-backdrop` | `z-[80]` |
|
|
151
|
+
| `z-drawer-content` | `z-[90]` |
|
|
152
|
+
|
|
153
|
+
### Dynamic
|
|
154
|
+
|
|
155
|
+
| Token | Expands to |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `badge-color-<name>` | a chip tinted by any palette color name (dark-aware) |
|
|
158
|
+
| `bg-glass` / `bg-glass:<n>` | translucent surface + `backdrop-blur` |
|
|
159
|
+
| `bg-dots` / `bg-dots-<n>` | radial dot-grid background, variable cell size in px (default 16) |
|
|
160
|
+
| `bg-grid` / `bg-grid-<n>` | crosshatch grid-lines background, variable cell size in px (default 16) |
|
|
161
|
+
<!-- TOKENS:END -->
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
[MIT](./LICENSE) © [Anthony Fu](https://github.com/antfu)
|
package/a11y/cli.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { ColorMode, ContrastScanOptions } from './scan'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { formatContrastReport, runContrastScan } from './scan'
|
|
5
|
+
|
|
6
|
+
const HELP = `antfu-design-a11y — color-contrast scan (light + dark)
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
antfu-design-a11y <url> [options]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--mode <light|dark> Scan a single mode (repeatable). Default: both.
|
|
13
|
+
--exclude <selector> Extra CSS selector to skip (repeatable).
|
|
14
|
+
--key <storageKey> localStorage key for color scheme. Default: antfu-design-color-scheme
|
|
15
|
+
--headed Run with a visible browser.
|
|
16
|
+
-h, --help Show this help.
|
|
17
|
+
|
|
18
|
+
Exits non-zero when any color-contrast violation is found.`
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv: string[]): { options: ContrastScanOptions, help: boolean } {
|
|
21
|
+
let url = ''
|
|
22
|
+
const modes: ColorMode[] = []
|
|
23
|
+
const exclude: string[] = []
|
|
24
|
+
let colorSchemeStorageKey: string | undefined
|
|
25
|
+
let headless = true
|
|
26
|
+
let help = false
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const arg = argv[i]
|
|
30
|
+
if (arg === '-h' || arg === '--help')
|
|
31
|
+
help = true
|
|
32
|
+
else if (arg === '--mode')
|
|
33
|
+
modes.push(argv[++i] as ColorMode)
|
|
34
|
+
else if (arg === '--exclude')
|
|
35
|
+
exclude.push(argv[++i])
|
|
36
|
+
else if (arg === '--key')
|
|
37
|
+
colorSchemeStorageKey = argv[++i]
|
|
38
|
+
else if (arg === '--headed')
|
|
39
|
+
headless = false
|
|
40
|
+
else if (!arg.startsWith('-') && !url)
|
|
41
|
+
url = arg
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
help,
|
|
46
|
+
options: {
|
|
47
|
+
url,
|
|
48
|
+
modes: modes.length ? modes : undefined,
|
|
49
|
+
exclude: exclude.length ? [...['.shiki', '[data-a11y-skip]'], ...exclude] : undefined,
|
|
50
|
+
colorSchemeStorageKey,
|
|
51
|
+
headless,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main(): Promise<void> {
|
|
57
|
+
const { options, help } = parseArgs(process.argv.slice(2))
|
|
58
|
+
|
|
59
|
+
if (help || !options.url) {
|
|
60
|
+
console.log(HELP)
|
|
61
|
+
process.exit(help ? 0 : 1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await runContrastScan(options)
|
|
65
|
+
|
|
66
|
+
console.log(formatContrastReport(result))
|
|
67
|
+
process.exit(result.passed ? 0 : 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main().catch((error) => {
|
|
71
|
+
console.error(error)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
})
|
package/a11y/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// WCAG contrast math is browser-free and reusable for unit-testing token pairs.
|
|
2
|
+
export {
|
|
3
|
+
checkContrast,
|
|
4
|
+
type ContrastLevel,
|
|
5
|
+
contrastRatio,
|
|
6
|
+
type ContrastResult,
|
|
7
|
+
meetsContrast,
|
|
8
|
+
parseColor,
|
|
9
|
+
relativeLuminance,
|
|
10
|
+
type RGB,
|
|
11
|
+
} from '../utils/contrast'
|
|
12
|
+
|
|
13
|
+
export * from './scan'
|
package/a11y/scan.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic color-contrast scan, adapted from config-inspector's
|
|
3
|
+
* `a11y.spec.ts`: launch a URL, toggle light **and** dark mode, run axe-core's
|
|
4
|
+
* `color-contrast` rule, and collect violations. `@axe-core/playwright` and
|
|
5
|
+
* `playwright` are optional peers, imported lazily so the rest of the package
|
|
6
|
+
* stays usable without them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ColorMode = 'light' | 'dark'
|
|
10
|
+
|
|
11
|
+
export interface ContrastScanOptions {
|
|
12
|
+
/** Target URL (an app or Storybook iframe). */
|
|
13
|
+
url: string
|
|
14
|
+
/** Modes to scan. Default `['light', 'dark']`. */
|
|
15
|
+
modes?: ColorMode[]
|
|
16
|
+
/** CSS selectors to exclude (e.g. code blocks). */
|
|
17
|
+
exclude?: string[]
|
|
18
|
+
/** localStorage key the app reads its color scheme from. */
|
|
19
|
+
colorSchemeStorageKey?: string
|
|
20
|
+
/** Stored value representing light mode. */
|
|
21
|
+
lightValue?: string
|
|
22
|
+
/** Stored value representing dark mode. */
|
|
23
|
+
darkValue?: string
|
|
24
|
+
/** Headless browser. Default `true`. */
|
|
25
|
+
headless?: boolean
|
|
26
|
+
/** Max ms to wait for the `dark` class to settle. Default `5000`. */
|
|
27
|
+
timeout?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContrastViolationNode {
|
|
31
|
+
target: string[]
|
|
32
|
+
failureSummary?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContrastViolation {
|
|
36
|
+
id: string
|
|
37
|
+
impact?: string | null
|
|
38
|
+
help: string
|
|
39
|
+
mode: ColorMode
|
|
40
|
+
nodes: ContrastViolationNode[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ContrastScanResult {
|
|
44
|
+
passed: boolean
|
|
45
|
+
violations: ContrastViolation[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_EXCLUDE = ['.shiki', '[data-a11y-skip]']
|
|
49
|
+
|
|
50
|
+
/** Run the contrast scan and return structured violations. */
|
|
51
|
+
export async function runContrastScan(options: ContrastScanOptions): Promise<ContrastScanResult> {
|
|
52
|
+
const {
|
|
53
|
+
url,
|
|
54
|
+
modes = ['light', 'dark'],
|
|
55
|
+
exclude = DEFAULT_EXCLUDE,
|
|
56
|
+
colorSchemeStorageKey = 'antfu-design-color-scheme',
|
|
57
|
+
lightValue = 'light',
|
|
58
|
+
darkValue = 'dark',
|
|
59
|
+
headless = true,
|
|
60
|
+
timeout = 5000,
|
|
61
|
+
} = options
|
|
62
|
+
|
|
63
|
+
const { chromium } = await import('playwright')
|
|
64
|
+
const { default: AxeBuilder } = await import('@axe-core/playwright')
|
|
65
|
+
|
|
66
|
+
const browser = await chromium.launch({ headless })
|
|
67
|
+
const violations: ContrastViolation[] = []
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
for (const mode of modes) {
|
|
71
|
+
const context = await browser.newContext()
|
|
72
|
+
const page = await context.newPage()
|
|
73
|
+
await page.addInitScript(
|
|
74
|
+
([key, value]) => {
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(key, value)
|
|
77
|
+
}
|
|
78
|
+
catch {}
|
|
79
|
+
},
|
|
80
|
+
[colorSchemeStorageKey, mode === 'dark' ? darkValue : lightValue] as [string, string],
|
|
81
|
+
)
|
|
82
|
+
await page.goto(url, { waitUntil: 'networkidle' })
|
|
83
|
+
await page.waitForFunction(
|
|
84
|
+
m => document.documentElement.classList.contains('dark') === (m === 'dark'),
|
|
85
|
+
mode,
|
|
86
|
+
{ timeout },
|
|
87
|
+
).catch(() => {})
|
|
88
|
+
|
|
89
|
+
let builder = new AxeBuilder({ page }).withRules(['color-contrast'])
|
|
90
|
+
for (const sel of exclude)
|
|
91
|
+
builder = builder.exclude(sel)
|
|
92
|
+
|
|
93
|
+
const results = await builder.analyze()
|
|
94
|
+
for (const v of results.violations) {
|
|
95
|
+
violations.push({
|
|
96
|
+
id: v.id,
|
|
97
|
+
impact: v.impact,
|
|
98
|
+
help: v.help,
|
|
99
|
+
mode,
|
|
100
|
+
nodes: v.nodes.map(n => ({
|
|
101
|
+
target: n.target.map(String),
|
|
102
|
+
failureSummary: n.failureSummary,
|
|
103
|
+
})),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
await context.close()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await browser.close()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { passed: violations.length === 0, violations }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Format a scan result as a readable, multi-line report. */
|
|
117
|
+
export function formatContrastReport(result: ContrastScanResult): string {
|
|
118
|
+
if (result.passed)
|
|
119
|
+
return '✓ No color-contrast violations found.'
|
|
120
|
+
const lines: string[] = [`✗ ${result.violations.length} color-contrast violation(s):`, '']
|
|
121
|
+
for (const v of result.violations) {
|
|
122
|
+
lines.push(` [${v.mode}] ${v.help}${v.impact ? ` (${v.impact})` : ''}`)
|
|
123
|
+
for (const node of v.nodes)
|
|
124
|
+
lines.push(` - ${node.target.join(' ')}`)
|
|
125
|
+
}
|
|
126
|
+
return lines.join('\n')
|
|
127
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import ActionButton from './ActionButton.vue'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Action/ActionButton',
|
|
6
|
+
component: ActionButton,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
argTypes: {
|
|
9
|
+
variant: { control: 'inline-radio', options: ['action', 'primary', 'text'] },
|
|
10
|
+
size: { control: 'inline-radio', options: ['sm', 'md'] },
|
|
11
|
+
},
|
|
12
|
+
args: { variant: 'action', size: 'md' },
|
|
13
|
+
} satisfies Meta<typeof ActionButton>
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof meta>
|
|
17
|
+
|
|
18
|
+
export const Action: Story = {
|
|
19
|
+
render: (args: Record<string, unknown>) => ({
|
|
20
|
+
components: { ActionButton },
|
|
21
|
+
setup() {
|
|
22
|
+
return { args }
|
|
23
|
+
},
|
|
24
|
+
template: `<ActionButton v-bind="args">Action</ActionButton>`,
|
|
25
|
+
}),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const Primary: Story = {
|
|
29
|
+
render: () => ({
|
|
30
|
+
components: { ActionButton },
|
|
31
|
+
template: `<ActionButton variant="primary">Primary</ActionButton>`,
|
|
32
|
+
}),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const Variants: Story = {
|
|
36
|
+
render: () => ({
|
|
37
|
+
components: { ActionButton },
|
|
38
|
+
template: `<div class="flex items-center gap-3">
|
|
39
|
+
<ActionButton>Action</ActionButton>
|
|
40
|
+
<ActionButton variant="primary">Primary</ActionButton>
|
|
41
|
+
<ActionButton variant="text">Text</ActionButton>
|
|
42
|
+
<ActionButton :loading="true">Loading</ActionButton>
|
|
43
|
+
<ActionButton :disabled="true">Disabled</ActionButton>
|
|
44
|
+
</div>`,
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const WithIconAndSizes: Story = {
|
|
49
|
+
render: () => ({
|
|
50
|
+
components: { ActionButton },
|
|
51
|
+
template: `<div class="flex items-center gap-3">
|
|
52
|
+
<ActionButton icon="i-ph:folder" size="sm">Small</ActionButton>
|
|
53
|
+
<ActionButton icon="i-ph:folder" size="md">Medium</ActionButton>
|
|
54
|
+
</div>`,
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import FeedbackSpinner from '../Feedback/FeedbackSpinner.vue'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
/** Render as another element/component (e.g. `RouterLink`). */
|
|
8
|
+
as?: string
|
|
9
|
+
to?: string
|
|
10
|
+
href?: string
|
|
11
|
+
icon?: string
|
|
12
|
+
variant?: 'action' | 'primary' | 'text'
|
|
13
|
+
size?: 'sm' | 'md'
|
|
14
|
+
loading?: boolean
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}>(),
|
|
17
|
+
{ variant: 'action', size: 'md' },
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const tag = computed(() => {
|
|
21
|
+
if (props.as)
|
|
22
|
+
return props.as
|
|
23
|
+
if (props.href != null || props.to != null)
|
|
24
|
+
return 'a'
|
|
25
|
+
return 'button'
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const isLink = computed(() => tag.value === 'a')
|
|
29
|
+
const isButton = computed(() => tag.value === 'button')
|
|
30
|
+
const sm = computed(() => props.size === 'sm')
|
|
31
|
+
|
|
32
|
+
const variantClass = computed(() => {
|
|
33
|
+
if (props.variant === 'primary')
|
|
34
|
+
return sm.value ? 'btn-primary text-sm px-2.5! py-1!' : 'btn-primary'
|
|
35
|
+
if (props.variant === 'text')
|
|
36
|
+
return `inline-flex items-center gap-1.5 op75 hover:op100 transition${sm.value ? ' text-sm' : ''}`
|
|
37
|
+
return sm.value ? 'btn-action-sm' : 'btn-action'
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const disabledState = computed(() => props.disabled || props.loading)
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<template>
|
|
44
|
+
<component
|
|
45
|
+
:is="tag"
|
|
46
|
+
:class="[variantClass, { 'pointer-events-none op-mute': disabledState && !isButton }]"
|
|
47
|
+
:href="isLink ? (href ?? to) : undefined"
|
|
48
|
+
:to="as && to != null ? to : undefined"
|
|
49
|
+
:disabled="isButton ? disabledState : undefined"
|
|
50
|
+
:aria-disabled="disabledState || undefined"
|
|
51
|
+
:aria-busy="loading || undefined"
|
|
52
|
+
>
|
|
53
|
+
<FeedbackSpinner v-if="loading" size="1em" />
|
|
54
|
+
<span v-else-if="icon" :class="icon" aria-hidden="true" />
|
|
55
|
+
<slot />
|
|
56
|
+
</component>
|
|
57
|
+
</template>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import ActionDarkToggle from './ActionDarkToggle.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Action/ActionDarkToggle',
|
|
7
|
+
component: ActionDarkToggle,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
} satisfies Meta<typeof ActionDarkToggle>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
// The component is controlled: the app owns the `colorScheme` and reacts to
|
|
15
|
+
// `@update:color-scheme`. Here a local ref also toggles the document class.
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
render: () => ({
|
|
18
|
+
components: { ActionDarkToggle },
|
|
19
|
+
setup() {
|
|
20
|
+
const colorScheme = ref<'light' | 'dark'>(
|
|
21
|
+
document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
|
22
|
+
)
|
|
23
|
+
function onUpdate(value: 'light' | 'dark'): void {
|
|
24
|
+
colorScheme.value = value
|
|
25
|
+
document.documentElement.classList.toggle('dark', value === 'dark')
|
|
26
|
+
}
|
|
27
|
+
return { colorScheme, onUpdate }
|
|
28
|
+
},
|
|
29
|
+
template: `<ActionDarkToggle :color-scheme="colorScheme" @update:color-scheme="onUpdate" />`,
|
|
30
|
+
}),
|
|
31
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(
|
|
5
|
+
defineProps<{
|
|
6
|
+
/** Current color scheme — the app owns this state. */
|
|
7
|
+
colorScheme?: 'light' | 'dark'
|
|
8
|
+
/** Disable the view-transition circular reveal. */
|
|
9
|
+
animated?: boolean
|
|
10
|
+
}>(),
|
|
11
|
+
{ colorScheme: 'light', animated: true },
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{ 'update:colorScheme': ['light' | 'dark'] }>()
|
|
15
|
+
|
|
16
|
+
const isDark = computed(() => props.colorScheme === 'dark')
|
|
17
|
+
|
|
18
|
+
function prefersReducedMotion(): boolean {
|
|
19
|
+
return typeof matchMedia !== 'undefined'
|
|
20
|
+
&& matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function onClick(event: MouseEvent): void {
|
|
24
|
+
const next = isDark.value ? 'light' : 'dark'
|
|
25
|
+
const doc = document as Document & {
|
|
26
|
+
startViewTransition?: (cb: () => Promise<void> | void) => { ready: Promise<void> }
|
|
27
|
+
}
|
|
28
|
+
const canAnimate = props.animated
|
|
29
|
+
&& typeof doc.startViewTransition === 'function'
|
|
30
|
+
&& !prefersReducedMotion()
|
|
31
|
+
|
|
32
|
+
if (!canAnimate) {
|
|
33
|
+
emit('update:colorScheme', next)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const x = event.clientX
|
|
38
|
+
const y = event.clientY
|
|
39
|
+
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
|
|
40
|
+
const toDark = next === 'dark'
|
|
41
|
+
|
|
42
|
+
const transition = doc.startViewTransition!(async () => {
|
|
43
|
+
emit('update:colorScheme', next)
|
|
44
|
+
await nextTick()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
transition.ready.then(() => {
|
|
48
|
+
const clip = [
|
|
49
|
+
`circle(0px at ${x}px ${y}px)`,
|
|
50
|
+
`circle(${endRadius}px at ${x}px ${y}px)`,
|
|
51
|
+
]
|
|
52
|
+
document.documentElement.animate(
|
|
53
|
+
{ clipPath: toDark ? clip : [...clip].reverse() },
|
|
54
|
+
{
|
|
55
|
+
duration: 400,
|
|
56
|
+
easing: 'ease-in',
|
|
57
|
+
pseudoElement: toDark ? '::view-transition-new(root)' : '::view-transition-old(root)',
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<slot :is-dark="isDark" :toggle="onClick">
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
class="btn-icon"
|
|
69
|
+
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
|
70
|
+
:aria-pressed="isDark"
|
|
71
|
+
@click="onClick"
|
|
72
|
+
>
|
|
73
|
+
<svg v-if="isDark" width="1.1em" height="1.1em" viewBox="0 0 24 24" aria-hidden="true">
|
|
74
|
+
<path
|
|
75
|
+
fill="currentColor"
|
|
76
|
+
d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.39 5.39 0 0 1-4.4 2.26 5.4 5.4 0 0 1-5.4-5.4c0-1.81.89-3.41 2.26-4.4-.44-.06-.9-.1-1.36-.1Z"
|
|
77
|
+
/>
|
|
78
|
+
</svg>
|
|
79
|
+
<svg v-else width="1.1em" height="1.1em" viewBox="0 0 24 24" aria-hidden="true">
|
|
80
|
+
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
81
|
+
<circle cx="12" cy="12" r="4" />
|
|
82
|
+
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4" />
|
|
83
|
+
</g>
|
|
84
|
+
</svg>
|
|
85
|
+
</button>
|
|
86
|
+
</slot>
|
|
87
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import ActionIconButton from './ActionIconButton.vue'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Action/ActionIconButton',
|
|
6
|
+
component: ActionIconButton,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
args: { icon: 'i-ph:folder', tooltip: 'Open folder' },
|
|
9
|
+
} satisfies Meta<typeof ActionIconButton>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
args: { icon: 'i-ph:folder', tooltip: 'Open folder' },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Active: Story = {
|
|
19
|
+
args: { icon: 'i-ph:gear', tooltip: 'Settings', active: true },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const States: Story = {
|
|
23
|
+
render: () => ({
|
|
24
|
+
components: { ActionIconButton },
|
|
25
|
+
template: `<div class="flex items-center gap-2">
|
|
26
|
+
<ActionIconButton icon="i-ph:folder" tooltip="Open folder" />
|
|
27
|
+
<ActionIconButton icon="i-ph:gear" tooltip="Settings" :active="true" />
|
|
28
|
+
<ActionIconButton icon="i-ph:trash" tooltip="Delete" :disabled="true" />
|
|
29
|
+
</div>`,
|
|
30
|
+
}),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const Compact: Story = {
|
|
34
|
+
args: { icon: 'i-ph:dots-three', tooltip: 'More', compact: true },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Sizes: Story = {
|
|
38
|
+
render: () => ({
|
|
39
|
+
components: { ActionIconButton },
|
|
40
|
+
template: `<div class="flex items-center gap-2">
|
|
41
|
+
<ActionIconButton icon="i-ph:gear" tooltip="sm" size="sm" />
|
|
42
|
+
<ActionIconButton icon="i-ph:gear" tooltip="md" size="md" />
|
|
43
|
+
<ActionIconButton icon="i-ph:gear" tooltip="lg" size="lg" />
|
|
44
|
+
<ActionIconButton icon="i-ph:gear" tooltip="compact" :compact="true" />
|
|
45
|
+
</div>`,
|
|
46
|
+
}),
|
|
47
|
+
}
|