@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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/a11y/cli.ts +73 -0
  4. package/a11y/index.ts +13 -0
  5. package/a11y/scan.ts +127 -0
  6. package/components/Action/ActionButton.stories.ts +56 -0
  7. package/components/Action/ActionButton.vue +57 -0
  8. package/components/Action/ActionDarkToggle.stories.ts +31 -0
  9. package/components/Action/ActionDarkToggle.vue +87 -0
  10. package/components/Action/ActionIconButton.stories.ts +47 -0
  11. package/components/Action/ActionIconButton.vue +47 -0
  12. package/components/Display/DisplayAvatar.stories.ts +36 -0
  13. package/components/Display/DisplayAvatar.vue +58 -0
  14. package/components/Display/DisplayBadge.stories.ts +31 -0
  15. package/components/Display/DisplayBadge.vue +98 -0
  16. package/components/Display/DisplayBytes.stories.ts +28 -0
  17. package/components/Display/DisplayBytes.vue +30 -0
  18. package/components/Display/DisplayDate.stories.ts +37 -0
  19. package/components/Display/DisplayDate.vue +29 -0
  20. package/components/Display/DisplayDonut.stories.ts +26 -0
  21. package/components/Display/DisplayDonut.vue +46 -0
  22. package/components/Display/DisplayDuration.stories.ts +28 -0
  23. package/components/Display/DisplayDuration.vue +28 -0
  24. package/components/Display/DisplayFileIcon.stories.ts +27 -0
  25. package/components/Display/DisplayFileIcon.vue +30 -0
  26. package/components/Display/DisplayFilePath.stories.ts +30 -0
  27. package/components/Display/DisplayFilePath.vue +61 -0
  28. package/components/Display/DisplayKbd.stories.ts +26 -0
  29. package/components/Display/DisplayKbd.vue +27 -0
  30. package/components/Display/DisplayKeyValue.stories.ts +56 -0
  31. package/components/Display/DisplayKeyValue.vue +51 -0
  32. package/components/Display/DisplayLabel.stories.ts +27 -0
  33. package/components/Display/DisplayLabel.vue +33 -0
  34. package/components/Display/DisplayNumber.stories.ts +27 -0
  35. package/components/Display/DisplayNumber.vue +24 -0
  36. package/components/Display/DisplayNumberBadge.stories.ts +26 -0
  37. package/components/Display/DisplayNumberBadge.vue +22 -0
  38. package/components/Display/DisplayPackageName.stories.ts +26 -0
  39. package/components/Display/DisplayPackageName.vue +49 -0
  40. package/components/Display/DisplayProgressBar.stories.ts +29 -0
  41. package/components/Display/DisplayProgressBar.vue +90 -0
  42. package/components/Display/DisplayProportionBar.stories.ts +40 -0
  43. package/components/Display/DisplayProportionBar.vue +43 -0
  44. package/components/Display/DisplaySafeImage.stories.ts +43 -0
  45. package/components/Display/DisplaySafeImage.vue +30 -0
  46. package/components/Display/DisplayStatusPill.stories.ts +34 -0
  47. package/components/Display/DisplayStatusPill.vue +42 -0
  48. package/components/Display/DisplayTree.stories.ts +76 -0
  49. package/components/Display/DisplayTree.vue +102 -0
  50. package/components/Display/DisplayVersion.stories.ts +25 -0
  51. package/components/Display/DisplayVersion.vue +21 -0
  52. package/components/Feedback/FeedbackEmptyState.stories.ts +38 -0
  53. package/components/Feedback/FeedbackEmptyState.vue +21 -0
  54. package/components/Feedback/FeedbackLoading.stories.ts +23 -0
  55. package/components/Feedback/FeedbackLoading.vue +21 -0
  56. package/components/Feedback/FeedbackSpinner.stories.ts +25 -0
  57. package/components/Feedback/FeedbackSpinner.vue +22 -0
  58. package/components/Feedback/FeedbackTip.stories.ts +34 -0
  59. package/components/Feedback/FeedbackTip.vue +29 -0
  60. package/components/Feedback/FeedbackToasts.stories.ts +40 -0
  61. package/components/Feedback/FeedbackToasts.vue +105 -0
  62. package/components/Form/FormCheckbox.stories.ts +36 -0
  63. package/components/Form/FormCheckbox.vue +30 -0
  64. package/components/Form/FormCombobox.stories.ts +35 -0
  65. package/components/Form/FormCombobox.vue +83 -0
  66. package/components/Form/FormField.stories.ts +56 -0
  67. package/components/Form/FormField.vue +36 -0
  68. package/components/Form/FormNumberInput.stories.ts +47 -0
  69. package/components/Form/FormNumberInput.vue +85 -0
  70. package/components/Form/FormRadioGroup.stories.ts +47 -0
  71. package/components/Form/FormRadioGroup.vue +43 -0
  72. package/components/Form/FormSearchField.stories.ts +22 -0
  73. package/components/Form/FormSearchField.vue +32 -0
  74. package/components/Form/FormSelect.stories.ts +47 -0
  75. package/components/Form/FormSelect.vue +56 -0
  76. package/components/Form/FormSwitch.stories.ts +36 -0
  77. package/components/Form/FormSwitch.vue +26 -0
  78. package/components/Form/FormTextInput.stories.ts +39 -0
  79. package/components/Form/FormTextInput.vue +51 -0
  80. package/components/Form/FormTextarea.stories.ts +47 -0
  81. package/components/Form/FormTextarea.vue +32 -0
  82. package/components/Layout/LayoutBreadcrumb.stories.ts +54 -0
  83. package/components/Layout/LayoutBreadcrumb.vue +54 -0
  84. package/components/Layout/LayoutCard.stories.ts +31 -0
  85. package/components/Layout/LayoutCard.vue +21 -0
  86. package/components/Layout/LayoutDataTable.stories.ts +77 -0
  87. package/components/Layout/LayoutDataTable.vue +145 -0
  88. package/components/Layout/LayoutExpandableList.stories.ts +28 -0
  89. package/components/Layout/LayoutExpandableList.vue +94 -0
  90. package/components/Layout/LayoutPanelGrids.stories.ts +28 -0
  91. package/components/Layout/LayoutPanelGrids.vue +26 -0
  92. package/components/Layout/LayoutSectionBlock.stories.ts +37 -0
  93. package/components/Layout/LayoutSectionBlock.vue +37 -0
  94. package/components/Layout/LayoutSideNav.stories.ts +33 -0
  95. package/components/Layout/LayoutSideNav.vue +48 -0
  96. package/components/Layout/LayoutSplitPane.stories.ts +44 -0
  97. package/components/Layout/LayoutSplitPane.vue +30 -0
  98. package/components/Layout/LayoutTabs.stories.ts +43 -0
  99. package/components/Layout/LayoutTabs.vue +56 -0
  100. package/components/Layout/LayoutToolbar.stories.ts +60 -0
  101. package/components/Layout/LayoutToolbar.vue +28 -0
  102. package/components/Layout/LayoutVirtualList.stories.ts +30 -0
  103. package/components/Layout/LayoutVirtualList.vue +82 -0
  104. package/components/Overlay/OverlayDrawer.stories.ts +47 -0
  105. package/components/Overlay/OverlayDrawer.vue +58 -0
  106. package/components/Overlay/OverlayDropdown.stories.ts +25 -0
  107. package/components/Overlay/OverlayDropdown.vue +30 -0
  108. package/components/Overlay/OverlayDropdownItem.stories.ts +26 -0
  109. package/components/Overlay/OverlayDropdownItem.vue +31 -0
  110. package/components/Overlay/OverlayDropdownLabel.vue +9 -0
  111. package/components/Overlay/OverlayDropdownSeparator.vue +7 -0
  112. package/components/Overlay/OverlayModal.stories.ts +33 -0
  113. package/components/Overlay/OverlayModal.vue +48 -0
  114. package/components/Overlay/OverlayTooltip.stories.ts +33 -0
  115. package/components/Overlay/OverlayTooltip.vue +38 -0
  116. package/composables/colorScheme.ts +58 -0
  117. package/composables/toast.ts +81 -0
  118. package/package.json +99 -0
  119. package/skills/antfu-design/SKILL.md +65 -0
  120. package/skills/antfu-design/references/advanced-patterns.md +39 -0
  121. package/skills/antfu-design/references/best-practices.md +54 -0
  122. package/skills/antfu-design/references/core-components.md +72 -0
  123. package/skills/antfu-design/references/core-setup.md +56 -0
  124. package/skills/antfu-design/references/core-tokens.md +100 -0
  125. package/skills/antfu-design/references/features-data-presentation.md +27 -0
  126. package/splitpanes.d.ts +70 -0
  127. package/styles/animations.css +47 -0
  128. package/styles/base.css +31 -0
  129. package/styles/floating-vue.css +28 -0
  130. package/styles/index.css +7 -0
  131. package/styles/reka-ui.css +112 -0
  132. package/styles/scrollbar.css +24 -0
  133. package/styles/splitpanes.css +61 -0
  134. package/unocss/colors.ts +127 -0
  135. package/unocss/index.ts +99 -0
  136. package/unocss/options.ts +31 -0
  137. package/unocss/patterns.ts +38 -0
  138. package/unocss/rules.ts +26 -0
  139. package/unocss/severity.ts +16 -0
  140. package/unocss/shortcuts.ts +68 -0
  141. package/utils/color.ts +328 -0
  142. package/utils/contrast.ts +118 -0
  143. package/utils/format.ts +389 -0
  144. package/utils/icon.ts +200 -0
  145. package/utils/index.ts +13 -0
  146. package/utils/keybinding.ts +199 -0
  147. package/utils/misc.ts +141 -0
  148. package/utils/path.ts +243 -0
  149. package/utils/semver.ts +147 -0
  150. 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">&nbsp;*</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>