@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export type ToastType = 'info' | 'success' | 'warning' | 'error'
|
|
3
|
+
|
|
4
|
+
/** A single toast. The **app** owns the list and the ids — this component is presentational. */
|
|
5
|
+
export interface ToastItem {
|
|
6
|
+
/** Stable identity, echoed back by the `dismiss` event. */
|
|
7
|
+
id: string | number
|
|
8
|
+
message: string
|
|
9
|
+
title?: string
|
|
10
|
+
type?: ToastType
|
|
11
|
+
/** Leading icon class (e.g. `i-ph:check-circle`). */
|
|
12
|
+
icon?: string
|
|
13
|
+
/** Auto-dismiss after this many ms (handled by `useToast`, not this component). */
|
|
14
|
+
duration?: number
|
|
15
|
+
/** Determinate progress, 0–1 — renders a progress bar (e.g. a download/scan). */
|
|
16
|
+
progress?: number
|
|
17
|
+
/** Optional action button label; pressing it fires the `action` event. */
|
|
18
|
+
action?: string
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script setup lang="ts">
|
|
23
|
+
withDefaults(
|
|
24
|
+
defineProps<{
|
|
25
|
+
/** The toast list — owned and mutated by the app (e.g. a local `ref`). */
|
|
26
|
+
items: ToastItem[]
|
|
27
|
+
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
|
|
28
|
+
}>(),
|
|
29
|
+
{ position: 'bottom-right' },
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
/** Fired when a toast's close button is pressed; the app removes it from `items`. */
|
|
34
|
+
dismiss: [id: string | number]
|
|
35
|
+
/** Fired when a toast's action button is pressed. */
|
|
36
|
+
action: [id: string | number]
|
|
37
|
+
}>()
|
|
38
|
+
|
|
39
|
+
const POSITION = {
|
|
40
|
+
'top-right': 'top-4 right-4',
|
|
41
|
+
'bottom-right': 'bottom-4 right-4',
|
|
42
|
+
'top-left': 'top-4 left-4',
|
|
43
|
+
'bottom-left': 'bottom-4 left-4',
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
const TYPE_CLASS: Record<ToastType, string> = {
|
|
47
|
+
info: 'color-active',
|
|
48
|
+
success: 'text-green-600 dark:text-green-400',
|
|
49
|
+
warning: 'text-amber-600 dark:text-amber-400',
|
|
50
|
+
error: 'text-red-600 dark:text-red-400',
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<Teleport to="body">
|
|
56
|
+
<div
|
|
57
|
+
class="flex flex-col gap-2 max-w-[calc(100vw-2rem)] w-80 fixed z-toast"
|
|
58
|
+
:class="POSITION[position]"
|
|
59
|
+
role="region"
|
|
60
|
+
aria-label="Notifications"
|
|
61
|
+
>
|
|
62
|
+
<TransitionGroup
|
|
63
|
+
enter-active-class="transition duration-250 ease-out"
|
|
64
|
+
leave-active-class="transition duration-250 ease-out absolute w-full"
|
|
65
|
+
enter-from-class="op0 translate-y-2 scale-98"
|
|
66
|
+
leave-to-class="op0 translate-y-2 scale-98"
|
|
67
|
+
>
|
|
68
|
+
<div
|
|
69
|
+
v-for="item in items"
|
|
70
|
+
:key="item.id"
|
|
71
|
+
class="p-3 border border-base rounded-lg bg-glass flex gap-2 shadow-lg items-start"
|
|
72
|
+
role="status"
|
|
73
|
+
aria-live="polite"
|
|
74
|
+
>
|
|
75
|
+
<span v-if="item.icon" :class="[item.icon, TYPE_CLASS[item.type ?? 'info']]" class="mt-0.5 shrink-0" aria-hidden="true" />
|
|
76
|
+
<div class="flex-1 min-w-0">
|
|
77
|
+
<div v-if="item.title" class="text-sm font-medium" :class="TYPE_CLASS[item.type ?? 'info']">
|
|
78
|
+
{{ item.title }}
|
|
79
|
+
</div>
|
|
80
|
+
<div class="text-sm color-muted break-words">
|
|
81
|
+
{{ item.message }}
|
|
82
|
+
</div>
|
|
83
|
+
<div v-if="item.progress != null" class="mt-1.5 rounded-full bg-active h-1 w-full overflow-hidden">
|
|
84
|
+
<div class="rounded-full h-full transition-all duration-300" :class="TYPE_CLASS[item.type ?? 'info']" style="background: currentColor" :style="{ width: `${Math.round(Math.max(0, Math.min(1, item.progress)) * 100)}%` }" />
|
|
85
|
+
</div>
|
|
86
|
+
<button
|
|
87
|
+
v-if="item.action"
|
|
88
|
+
type="button"
|
|
89
|
+
class="text-sm font-medium mt-1.5 outline-none rounded hover:underline focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
90
|
+
:class="TYPE_CLASS[item.type ?? 'info']"
|
|
91
|
+
@click="emit('action', item.id)"
|
|
92
|
+
>
|
|
93
|
+
{{ item.action }}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<button type="button" class="op-fade shrink-0 transition hover:op100" aria-label="Dismiss" @click="emit('dismiss', item.id)">
|
|
97
|
+
<svg width="0.9em" height="0.9em" viewBox="0 0 24 24" aria-hidden="true">
|
|
98
|
+
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
|
99
|
+
</svg>
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</TransitionGroup>
|
|
103
|
+
</div>
|
|
104
|
+
</Teleport>
|
|
105
|
+
</template>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FormCheckbox from './FormCheckbox.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Form/FormCheckbox',
|
|
7
|
+
component: FormCheckbox,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
} satisfies Meta<typeof FormCheckbox>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => ({
|
|
16
|
+
components: { FormCheckbox },
|
|
17
|
+
setup() {
|
|
18
|
+
return { checked: ref(true) }
|
|
19
|
+
},
|
|
20
|
+
template: `<FormCheckbox v-model="checked" label="Enable telemetry" />`,
|
|
21
|
+
}),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const States: Story = {
|
|
25
|
+
render: () => ({
|
|
26
|
+
components: { FormCheckbox },
|
|
27
|
+
setup() {
|
|
28
|
+
return { a: ref(true), b: ref(false), c: ref(true) }
|
|
29
|
+
},
|
|
30
|
+
template: `<div class="flex flex-col gap-3">
|
|
31
|
+
<FormCheckbox v-model="a" label="Checked" />
|
|
32
|
+
<FormCheckbox v-model="b" label="Unchecked" />
|
|
33
|
+
<FormCheckbox v-model="c" label="Disabled" disabled />
|
|
34
|
+
</div>`,
|
|
35
|
+
}),
|
|
36
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
label?: string
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const model = defineModel<boolean>({ default: false })
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<label
|
|
14
|
+
class="text-sm inline-flex gap-2 cursor-pointer select-none items-center"
|
|
15
|
+
:class="{ 'op50 pointer-events-none': disabled }"
|
|
16
|
+
>
|
|
17
|
+
<CheckboxRoot
|
|
18
|
+
v-model="model"
|
|
19
|
+
:disabled="disabled"
|
|
20
|
+
class="outline-none border border-base rounded bg-base flex h-4 w-4 transition items-center justify-center data-[state=checked]:border-primary-500 data-[state=checked]:bg-primary-500 focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
21
|
+
>
|
|
22
|
+
<CheckboxIndicator class="text-white">
|
|
23
|
+
<svg width="0.8em" height="0.8em" viewBox="0 0 24 24" aria-hidden="true">
|
|
24
|
+
<path fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M20 6L9 17l-5-5" />
|
|
25
|
+
</svg>
|
|
26
|
+
</CheckboxIndicator>
|
|
27
|
+
</CheckboxRoot>
|
|
28
|
+
<span v-if="label || $slots.default"><slot>{{ label }}</slot></span>
|
|
29
|
+
</label>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FormCombobox from './FormCombobox.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Form/FormCombobox',
|
|
7
|
+
component: FormCombobox,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: { options: [] }, // required prop; each story supplies real options via render
|
|
10
|
+
} satisfies Meta<typeof FormCombobox>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
const frameworks = [
|
|
16
|
+
{ value: 'vue', label: 'Vue' },
|
|
17
|
+
{ value: 'react', label: 'React' },
|
|
18
|
+
{ value: 'svelte', label: 'Svelte' },
|
|
19
|
+
{ value: 'solid', label: 'Solid' },
|
|
20
|
+
{ value: 'angular', label: 'Angular' },
|
|
21
|
+
{ value: 'qwik', label: 'Qwik' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export const Default: Story = {
|
|
25
|
+
render: () => ({
|
|
26
|
+
components: { FormCombobox },
|
|
27
|
+
setup() {
|
|
28
|
+
return {
|
|
29
|
+
value: ref('vue'),
|
|
30
|
+
options: frameworks,
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
template: `<FormCombobox v-model="value" :options="options" placeholder="Search a framework…" />`,
|
|
34
|
+
}),
|
|
35
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ComboboxAnchor, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxPortal, ComboboxRoot, ComboboxViewport } from 'reka-ui'
|
|
3
|
+
|
|
4
|
+
export interface ComboboxOption {
|
|
5
|
+
value: string
|
|
6
|
+
label?: string
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
/** Selectable options. `label` falls back to `value`; `disabled` blocks selection. */
|
|
13
|
+
options: ComboboxOption[]
|
|
14
|
+
/** Placeholder shown in the search input while empty. */
|
|
15
|
+
placeholder?: string
|
|
16
|
+
/** Disable the whole combobox. */
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
}>(),
|
|
19
|
+
{},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/** The selected option's `value`. */
|
|
23
|
+
const model = defineModel<string>()
|
|
24
|
+
|
|
25
|
+
// reka-ui filters items by their `textValue` against the typed query, so we
|
|
26
|
+
// surface the visible label as the search text — this keeps filtering correct
|
|
27
|
+
// even when the `#option` slot customises rendering.
|
|
28
|
+
function optionLabel(option: ComboboxOption) {
|
|
29
|
+
return option.label ?? option.value
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<ComboboxRoot v-model="model" :disabled="disabled">
|
|
35
|
+
<ComboboxAnchor
|
|
36
|
+
class="text-sm px-2 border border-base rounded bg-base inline-flex gap-2 h-9 min-w-40 transition items-center data-[disabled]:op50 data-[disabled]:pointer-events-none focus-within:ring-2 focus-within:ring-primary-500/40"
|
|
37
|
+
>
|
|
38
|
+
<svg width="1em" height="1em" viewBox="0 0 24 24" class="op-fade shrink-0" aria-hidden="true">
|
|
39
|
+
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
40
|
+
<circle cx="11" cy="11" r="7" />
|
|
41
|
+
<path d="m21 21l-4.3-4.3" />
|
|
42
|
+
</g>
|
|
43
|
+
</svg>
|
|
44
|
+
<ComboboxInput
|
|
45
|
+
:placeholder="placeholder ?? 'Search…'"
|
|
46
|
+
class="color-base outline-none bg-transparent flex-1 min-w-0 placeholder:op-mute"
|
|
47
|
+
/>
|
|
48
|
+
</ComboboxAnchor>
|
|
49
|
+
<ComboboxPortal>
|
|
50
|
+
<ComboboxContent
|
|
51
|
+
position="popper"
|
|
52
|
+
:side-offset="6"
|
|
53
|
+
class="p-1 border border-base rounded-lg bg-base min-w-[--reka-combobox-trigger-width] shadow-lg z-dropdown"
|
|
54
|
+
data-af-animate
|
|
55
|
+
>
|
|
56
|
+
<ComboboxViewport>
|
|
57
|
+
<ComboboxItem
|
|
58
|
+
v-for="opt in options"
|
|
59
|
+
:key="opt.value"
|
|
60
|
+
:value="opt.value"
|
|
61
|
+
:text-value="optionLabel(opt)"
|
|
62
|
+
:disabled="opt.disabled"
|
|
63
|
+
class="text-sm color-base px-2 py-1.5 outline-none rounded-md flex gap-2 cursor-pointer select-none transition items-center data-[highlighted]:bg-active data-[disabled]:op50 data-[disabled]:pointer-events-none"
|
|
64
|
+
>
|
|
65
|
+
<span class="flex-1">
|
|
66
|
+
<slot name="option" :option="opt">{{ optionLabel(opt) }}</slot>
|
|
67
|
+
</span>
|
|
68
|
+
<ComboboxItemIndicator class="color-active inline-flex shrink-0 items-center">
|
|
69
|
+
<svg width="0.85em" height="0.85em" viewBox="0 0 24 24" aria-hidden="true">
|
|
70
|
+
<path fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" d="M20 6L9 17l-5-5" />
|
|
71
|
+
</svg>
|
|
72
|
+
</ComboboxItemIndicator>
|
|
73
|
+
</ComboboxItem>
|
|
74
|
+
<ComboboxEmpty class="text-sm px-2 py-1.5 text-center op-fade">
|
|
75
|
+
<slot name="empty">
|
|
76
|
+
No results
|
|
77
|
+
</slot>
|
|
78
|
+
</ComboboxEmpty>
|
|
79
|
+
</ComboboxViewport>
|
|
80
|
+
</ComboboxContent>
|
|
81
|
+
</ComboboxPortal>
|
|
82
|
+
</ComboboxRoot>
|
|
83
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FormField from './FormField.vue'
|
|
4
|
+
import FormTextInput from './FormTextInput.vue'
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Form/FormField',
|
|
8
|
+
component: FormField,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
args: { label: 'Project name' },
|
|
11
|
+
} satisfies Meta<typeof FormField>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
render: args => ({
|
|
18
|
+
components: { FormField, FormTextInput },
|
|
19
|
+
setup() {
|
|
20
|
+
return { args, text: ref('') }
|
|
21
|
+
},
|
|
22
|
+
template: `<div class="w-72">
|
|
23
|
+
<FormField v-bind="args" id="project-name" description="Shown publicly on your profile.">
|
|
24
|
+
<FormTextInput v-model="text" id="project-name" placeholder="my-awesome-lib" clearable />
|
|
25
|
+
</FormField>
|
|
26
|
+
</div>`,
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Required: Story = {
|
|
31
|
+
render: args => ({
|
|
32
|
+
components: { FormField, FormTextInput },
|
|
33
|
+
setup() {
|
|
34
|
+
return { args, text: ref('') }
|
|
35
|
+
},
|
|
36
|
+
template: `<div class="w-72">
|
|
37
|
+
<FormField v-bind="args" id="project-required" required description="This field is mandatory.">
|
|
38
|
+
<FormTextInput v-model="text" id="project-required" placeholder="my-awesome-lib" />
|
|
39
|
+
</FormField>
|
|
40
|
+
</div>`,
|
|
41
|
+
}),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const WithError: Story = {
|
|
45
|
+
render: args => ({
|
|
46
|
+
components: { FormField, FormTextInput },
|
|
47
|
+
setup() {
|
|
48
|
+
return { args, text: ref('') }
|
|
49
|
+
},
|
|
50
|
+
template: `<div class="w-72">
|
|
51
|
+
<FormField v-bind="args" id="project-error" required error="A project name is required.">
|
|
52
|
+
<FormTextInput v-model="text" id="project-error" placeholder="my-awesome-lib" />
|
|
53
|
+
</FormField>
|
|
54
|
+
</div>`,
|
|
55
|
+
}),
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
/** Visible field label. Overridable via the `#label` slot. */
|
|
5
|
+
label?: string
|
|
6
|
+
/** Helper text shown below the control. Overridable via the `#description` slot. */
|
|
7
|
+
description?: string
|
|
8
|
+
/** Error message shown below the control (with `role="alert"`). Overridable via the `#error` slot. */
|
|
9
|
+
error?: string
|
|
10
|
+
/** Show a red `*` marker after the label. */
|
|
11
|
+
required?: boolean
|
|
12
|
+
/** Associates the `<label>` with a control via its `id`. */
|
|
13
|
+
id?: string
|
|
14
|
+
}>(),
|
|
15
|
+
{},
|
|
16
|
+
)
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div class="flex flex-col gap-1">
|
|
21
|
+
<label v-if="label || $slots.label" :for="id" class="text-sm color-base font-medium">
|
|
22
|
+
<slot name="label">{{ label }}<span v-if="required" class="text-red-500" aria-hidden="true"> *</span></slot>
|
|
23
|
+
</label>
|
|
24
|
+
<slot />
|
|
25
|
+
<p v-if="description || $slots.description" class="text-sm color-faint">
|
|
26
|
+
<slot name="description">
|
|
27
|
+
{{ description }}
|
|
28
|
+
</slot>
|
|
29
|
+
</p>
|
|
30
|
+
<p v-if="error || $slots.error" role="alert" class="text-sm text-red-600 dark:text-red-400">
|
|
31
|
+
<slot name="error">
|
|
32
|
+
{{ error }}
|
|
33
|
+
</slot>
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FormNumberInput from './FormNumberInput.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Form/FormNumberInput',
|
|
7
|
+
component: FormNumberInput,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: { step: 1, controls: true },
|
|
10
|
+
} satisfies Meta<typeof FormNumberInput>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: args => ({
|
|
17
|
+
components: { FormNumberInput },
|
|
18
|
+
setup() {
|
|
19
|
+
return { args, value: ref<number | undefined>(3) }
|
|
20
|
+
},
|
|
21
|
+
template: `<div class="w-40"><FormNumberInput v-bind="args" v-model="value" placeholder="0" /></div>`,
|
|
22
|
+
}),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MinMax: Story = {
|
|
26
|
+
render: args => ({
|
|
27
|
+
components: { FormNumberInput },
|
|
28
|
+
setup() {
|
|
29
|
+
return { args, value: ref<number | undefined>(5) }
|
|
30
|
+
},
|
|
31
|
+
template: `<div class="w-40"><FormNumberInput v-bind="args" v-model="value" :min="0" :max="10" /></div>`,
|
|
32
|
+
}),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const States: Story = {
|
|
36
|
+
render: () => ({
|
|
37
|
+
components: { FormNumberInput },
|
|
38
|
+
setup() {
|
|
39
|
+
return { a: ref<number | undefined>(2), b: ref<number | undefined>(8), c: ref<number | undefined>(42) }
|
|
40
|
+
},
|
|
41
|
+
template: `<div class="flex flex-col gap-3 w-40">
|
|
42
|
+
<FormNumberInput v-model="a" :min="0" :max="10" :step="2" />
|
|
43
|
+
<FormNumberInput v-model="b" :controls="false" placeholder="No controls" />
|
|
44
|
+
<FormNumberInput v-model="c" invalid />
|
|
45
|
+
</div>`,
|
|
46
|
+
}),
|
|
47
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { clamp } from '../../utils'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
min?: number
|
|
8
|
+
max?: number
|
|
9
|
+
/** Increment/decrement applied by the stepper buttons. */
|
|
10
|
+
step?: number
|
|
11
|
+
placeholder?: string
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
/** Render the invalid (red) state. */
|
|
14
|
+
invalid?: boolean
|
|
15
|
+
/** Show the −/+ stepper buttons. */
|
|
16
|
+
controls?: boolean
|
|
17
|
+
}>(),
|
|
18
|
+
{ step: 1, controls: true },
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const model = defineModel<number>()
|
|
22
|
+
|
|
23
|
+
function bounded(value: number): number {
|
|
24
|
+
return clamp(value, props.min ?? -Infinity, props.max ?? Infinity)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function onInput(event: Event): void {
|
|
28
|
+
const raw = (event.target as HTMLInputElement).value
|
|
29
|
+
model.value = raw === '' ? undefined : Number(raw)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stepBy(direction: number): void {
|
|
33
|
+
model.value = bounded((model.value ?? 0) + direction * props.step)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const atMin = computed(() => props.min != null && model.value != null && model.value <= props.min)
|
|
37
|
+
const atMax = computed(() => props.max != null && model.value != null && model.value >= props.max)
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<div
|
|
42
|
+
class="px-2 border rounded bg-base inline-flex gap-1 h-9 transition items-center focus-within:ring-2"
|
|
43
|
+
:class="[
|
|
44
|
+
invalid ? 'border-red-500/60 focus-within:ring-red-500/40' : 'border-base focus-within:ring-primary-500/40',
|
|
45
|
+
{ 'op50 pointer-events-none': disabled },
|
|
46
|
+
]"
|
|
47
|
+
>
|
|
48
|
+
<button
|
|
49
|
+
v-if="controls"
|
|
50
|
+
type="button"
|
|
51
|
+
class="op-fade shrink-0 transition disabled:op-mute hover:op100 disabled:pointer-events-none"
|
|
52
|
+
:disabled="disabled || atMin"
|
|
53
|
+
aria-label="Decrement"
|
|
54
|
+
@click="stepBy(-1)"
|
|
55
|
+
>
|
|
56
|
+
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
|
|
57
|
+
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M5 12h14" />
|
|
58
|
+
</svg>
|
|
59
|
+
</button>
|
|
60
|
+
<input
|
|
61
|
+
:value="model"
|
|
62
|
+
type="number"
|
|
63
|
+
:min="min"
|
|
64
|
+
:max="max"
|
|
65
|
+
:step="step"
|
|
66
|
+
:placeholder="placeholder"
|
|
67
|
+
:disabled="disabled"
|
|
68
|
+
:aria-invalid="invalid || undefined"
|
|
69
|
+
class="color-base outline-none bg-transparent flex-1 min-w-0"
|
|
70
|
+
@input="onInput"
|
|
71
|
+
>
|
|
72
|
+
<button
|
|
73
|
+
v-if="controls"
|
|
74
|
+
type="button"
|
|
75
|
+
class="op-fade shrink-0 transition disabled:op-mute hover:op100 disabled:pointer-events-none"
|
|
76
|
+
:disabled="disabled || atMax"
|
|
77
|
+
aria-label="Increment"
|
|
78
|
+
@click="stepBy(1)"
|
|
79
|
+
>
|
|
80
|
+
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
|
|
81
|
+
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M12 5v14M5 12h14" />
|
|
82
|
+
</svg>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FormRadioGroup from './FormRadioGroup.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Form/FormRadioGroup',
|
|
7
|
+
component: FormRadioGroup,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
args: { options: [] }, // required prop; each story supplies real options via render
|
|
10
|
+
} satisfies Meta<typeof FormRadioGroup>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const Default: Story = {
|
|
16
|
+
render: () => ({
|
|
17
|
+
components: { FormRadioGroup },
|
|
18
|
+
setup() {
|
|
19
|
+
return {
|
|
20
|
+
value: ref('wind4'),
|
|
21
|
+
options: [
|
|
22
|
+
{ value: 'wind4', label: 'Wind 4' },
|
|
23
|
+
{ value: 'wind3', label: 'Wind 3' },
|
|
24
|
+
{ value: 'mini', label: 'Mini' },
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
template: `<FormRadioGroup v-model="value" :options="options" />`,
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const Horizontal: Story = {
|
|
33
|
+
render: () => ({
|
|
34
|
+
components: { FormRadioGroup },
|
|
35
|
+
setup() {
|
|
36
|
+
return {
|
|
37
|
+
value: ref('a'),
|
|
38
|
+
options: [
|
|
39
|
+
{ value: 'a', label: 'Option A' },
|
|
40
|
+
{ value: 'b', label: 'Option B' },
|
|
41
|
+
{ value: 'c', label: 'Disabled', disabled: true },
|
|
42
|
+
],
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
template: `<FormRadioGroup v-model="value" :options="options" orientation="horizontal" />`,
|
|
46
|
+
}),
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from 'reka-ui'
|
|
3
|
+
|
|
4
|
+
export interface RadioOption {
|
|
5
|
+
value: string
|
|
6
|
+
label?: string
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
options: RadioOption[]
|
|
13
|
+
orientation?: 'horizontal' | 'vertical'
|
|
14
|
+
}>(),
|
|
15
|
+
{ orientation: 'vertical' },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const model = defineModel<string>()
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<RadioGroupRoot
|
|
23
|
+
v-model="model"
|
|
24
|
+
class="flex gap-3"
|
|
25
|
+
:class="orientation === 'vertical' ? 'flex-col' : 'flex-row flex-wrap'"
|
|
26
|
+
>
|
|
27
|
+
<label
|
|
28
|
+
v-for="opt in options"
|
|
29
|
+
:key="opt.value"
|
|
30
|
+
class="text-sm inline-flex gap-2 cursor-pointer select-none items-center"
|
|
31
|
+
:class="{ 'op50 pointer-events-none': opt.disabled }"
|
|
32
|
+
>
|
|
33
|
+
<RadioGroupItem
|
|
34
|
+
:value="opt.value"
|
|
35
|
+
:disabled="opt.disabled"
|
|
36
|
+
class="outline-none border border-base rounded-full bg-base flex h-4 w-4 transition items-center justify-center data-[state=checked]:border-primary-500 focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
|
37
|
+
>
|
|
38
|
+
<RadioGroupIndicator class="rounded-full bg-primary-500 h-2 w-2 block" />
|
|
39
|
+
</RadioGroupItem>
|
|
40
|
+
{{ opt.label ?? opt.value }}
|
|
41
|
+
</label>
|
|
42
|
+
</RadioGroupRoot>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FormSearchField from './FormSearchField.vue'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Form/FormSearchField',
|
|
7
|
+
component: FormSearchField,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
} satisfies Meta<typeof FormSearchField>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => ({
|
|
16
|
+
components: { FormSearchField },
|
|
17
|
+
setup() {
|
|
18
|
+
return { search: ref('') }
|
|
19
|
+
},
|
|
20
|
+
template: `<div class="w-72"><FormSearchField v-model="search" shortcut="mod+k" /></div>`,
|
|
21
|
+
}),
|
|
22
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import DisplayKbd from '../Display/DisplayKbd.vue'
|
|
3
|
+
import FormTextInput from './FormTextInput.vue'
|
|
4
|
+
|
|
5
|
+
withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
placeholder?: string
|
|
8
|
+
/** A keyboard hint shown while empty, e.g. `mod+k`. */
|
|
9
|
+
shortcut?: string
|
|
10
|
+
size?: 'sm' | 'md'
|
|
11
|
+
}>(),
|
|
12
|
+
{ placeholder: 'Search…' },
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const model = defineModel<string>({ default: '' })
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<FormTextInput v-model="model" :placeholder="placeholder" :size="size" clearable type="search">
|
|
20
|
+
<template #prefix>
|
|
21
|
+
<svg width="1em" height="1em" viewBox="0 0 24 24" class="op-fade shrink-0" aria-hidden="true">
|
|
22
|
+
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
23
|
+
<circle cx="11" cy="11" r="7" />
|
|
24
|
+
<path d="m21 21l-4.3-4.3" />
|
|
25
|
+
</g>
|
|
26
|
+
</svg>
|
|
27
|
+
</template>
|
|
28
|
+
<template #suffix>
|
|
29
|
+
<DisplayKbd v-if="shortcut && !model" :keys="shortcut" />
|
|
30
|
+
</template>
|
|
31
|
+
</FormTextInput>
|
|
32
|
+
</template>
|