@aleph-alpha/ui-library 1.16.0 → 1.17.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 (31) hide show
  1. package/dist/system/index.d.ts +585 -402
  2. package/dist/system/lib.js +13063 -12825
  3. package/package.json +3 -4
  4. package/src/components/UiChip/UiChip.stories.ts +239 -0
  5. package/src/components/UiChip/UiChip.vue +128 -0
  6. package/src/components/UiChip/__tests__/UiChip.test.ts +102 -0
  7. package/src/components/UiChip/index.ts +2 -0
  8. package/src/components/UiChip/types.ts +50 -0
  9. package/src/components/UiDropdownMenu/UiDropdownMenu.stories.ts +259 -1
  10. package/src/components/UiField/UiField.stories.ts +589 -249
  11. package/src/components/UiField/UiField.vue +87 -2
  12. package/src/components/UiField/UiFieldDescription.vue +22 -1
  13. package/src/components/UiField/UiFieldLabel.vue +2 -0
  14. package/src/components/UiField/UiFieldLabelInfo.vue +22 -0
  15. package/src/components/UiField/index.ts +1 -0
  16. package/src/components/UiField/keys.ts +5 -0
  17. package/src/components/UiField/types.ts +86 -1
  18. package/src/components/UiSelect/__tests__/UiSelectTrigger.test.ts +47 -2
  19. package/src/components/UiToggle/UiToggle.stories.ts +54 -1
  20. package/src/components/UiToggle/__tests__/UiToggle.test.ts +15 -0
  21. package/src/components/UiToggle/types.ts +1 -1
  22. package/src/components/UiToggleGroup/__tests__/UiToggleGroup.test.ts +21 -0
  23. package/src/components/UiToggleGroup/types.ts +2 -2
  24. package/src/components/core/button/index.ts +8 -3
  25. package/src/components/core/field/FieldLabel.vue +1 -1
  26. package/src/components/core/field/index.ts +5 -5
  27. package/src/components/core/select/SelectTrigger.vue +1 -1
  28. package/src/components/core/toggle/Toggle.vue +3 -3
  29. package/src/components/core/toggle/index.ts +6 -3
  30. package/src/components/core/toggle-group/index.ts +6 -3
  31. package/src/components/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/ui-library",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "dist/system/lib.js",
@@ -71,14 +71,13 @@
71
71
  "vitest": "^3.0.0",
72
72
  "vue-tsc": "^2.2.12",
73
73
  "wait-on": "9.0.3",
74
- "@aleph-alpha/prettier-config-frontend": "0.4.0",
75
74
  "@aleph-alpha/eslint-config-frontend": "0.5.0",
76
- "@aleph-alpha/config-css": "0.21.0",
75
+ "@aleph-alpha/prettier-config-frontend": "0.4.0",
77
76
  "@aleph-alpha/tsconfig-frontend": "0.5.0"
78
77
  },
79
78
  "peerDependencies": {
80
- "unocss": ">=66.0.0",
81
79
  "@unocss/preset-wind4": ">=66.0.0",
80
+ "unocss": ">=66.0.0",
82
81
  "unocss-preset-animations": ">=1.0.0",
83
82
  "unocss-preset-shadcn": ">=1.0.0"
84
83
  },
@@ -0,0 +1,239 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
2
+ import { ref } from 'vue';
3
+ import UiChip from './UiChip.vue';
4
+ import { UiIcon } from '../UiIcon';
5
+
6
+ const TagIcon = {
7
+ components: { UiIcon },
8
+ template: '<UiIcon name="tag" :size="14" />',
9
+ };
10
+
11
+ const InfoIcon = {
12
+ components: { UiIcon },
13
+ template: '<UiIcon name="info" :size="14" />',
14
+ };
15
+
16
+ const meta: Meta<typeof UiChip> = {
17
+ title: 'Components/UiChip',
18
+ component: UiChip,
19
+ tags: ['autodocs'],
20
+ argTypes: {
21
+ variant: {
22
+ control: 'select',
23
+ options: ['static', 'selectable', 'removable'],
24
+ description: 'Behavior variant of the chip',
25
+ },
26
+ selectable: {
27
+ control: 'boolean',
28
+ description: 'Enables selection for removable chips',
29
+ },
30
+ disabled: {
31
+ control: 'boolean',
32
+ description: 'Disables interactions',
33
+ },
34
+ startIcon: {
35
+ control: false,
36
+ description: 'Optional leading icon',
37
+ },
38
+ removeLabel: {
39
+ control: 'text',
40
+ description: 'Accessible label for remove button',
41
+ },
42
+ modelValue: {
43
+ control: 'boolean',
44
+ description: 'Selection state for interactive chips',
45
+ },
46
+ },
47
+ args: {
48
+ variant: 'static',
49
+ selectable: false,
50
+ disabled: false,
51
+ removeLabel: 'Remove chip',
52
+ modelValue: false,
53
+ },
54
+ };
55
+
56
+ export default meta;
57
+
58
+ type Story = StoryObj<typeof UiChip>;
59
+
60
+ /**
61
+ * Static chips are non-interactive and work as labels/tags.
62
+ */
63
+ export const Static: Story = {
64
+ render: () => ({
65
+ components: { UiChip },
66
+ setup() {
67
+ return { TagIcon };
68
+ },
69
+ template: `
70
+ <div class="flex flex-wrap items-center gap-3">
71
+ <UiChip variant="static">Data Analysis</UiChip>
72
+ <UiChip variant="static" :start-icon="TagIcon">With icon</UiChip>
73
+ <UiChip variant="static" disabled>Disabled</UiChip>
74
+ </div>
75
+ `,
76
+ }),
77
+ };
78
+
79
+ /**
80
+ * Selectable chips support hover, focus, selected and disabled states.
81
+ */
82
+ export const Selectable: Story = {
83
+ render: () => ({
84
+ components: { UiChip },
85
+ setup() {
86
+ const isSelected = ref(false);
87
+ const isActive = ref(true);
88
+ return { isSelected, isActive, InfoIcon };
89
+ },
90
+ template: `
91
+ <div class="flex flex-wrap items-center gap-3">
92
+ <UiChip v-model="isSelected" variant="selectable">Default</UiChip>
93
+ <UiChip v-model="isActive" variant="selectable" :start-icon="InfoIcon">Active</UiChip>
94
+ <UiChip variant="selectable" disabled>Disabled</UiChip>
95
+ </div>
96
+ `,
97
+ }),
98
+ };
99
+
100
+ /**
101
+ * Removable chips include a dismiss action and can also be selectable.
102
+ */
103
+ export const Removable: Story = {
104
+ render: () => ({
105
+ components: { UiChip },
106
+ setup() {
107
+ const selectedRemovable = ref(true);
108
+ const chips = ref(['Data Analysis', 'Report Generation']);
109
+
110
+ function removeChip(label: string) {
111
+ chips.value = chips.value.filter((chip) => chip !== label);
112
+ }
113
+
114
+ return { chips, selectedRemovable, removeChip, TagIcon };
115
+ },
116
+ template: `
117
+ <div class="flex flex-wrap items-center gap-3">
118
+ <UiChip
119
+ v-for="chip in chips"
120
+ :key="chip"
121
+ variant="removable"
122
+ :start-icon="TagIcon"
123
+ @remove="removeChip(chip)"
124
+ >
125
+ {{ chip }}
126
+ </UiChip>
127
+
128
+ <UiChip
129
+ v-model="selectedRemovable"
130
+ variant="removable"
131
+ selectable
132
+ :start-icon="TagIcon"
133
+ >
134
+ Selectable removable
135
+ </UiChip>
136
+ </div>
137
+ `,
138
+ }),
139
+ };
140
+
141
+ /**
142
+ * Chips with and without a leading icon.
143
+ */
144
+ export const WithAndWithoutLeadingIcon: Story = {
145
+ render: () => ({
146
+ components: { UiChip },
147
+ setup() {
148
+ const selectableWithoutIcon = ref(true);
149
+ const selectableWithIcon = ref(false);
150
+ return { TagIcon, selectableWithoutIcon, selectableWithIcon };
151
+ },
152
+ template: `
153
+ <div class="flex flex-wrap items-center gap-3">
154
+ <UiChip variant="static">No icon</UiChip>
155
+ <UiChip variant="static" :start-icon="TagIcon">Leading icon</UiChip>
156
+ <UiChip v-model="selectableWithoutIcon" variant="selectable">Selectable no icon</UiChip>
157
+ <UiChip v-model="selectableWithIcon" variant="selectable" :start-icon="TagIcon">
158
+ Selectable with icon
159
+ </UiChip>
160
+ </div>
161
+ `,
162
+ }),
163
+ };
164
+
165
+ /**
166
+ * Group usage with keyboard-focusable interactive chips.
167
+ * Focus order is left-to-right, top-to-bottom across interactive chips and remove buttons.
168
+ */
169
+ export const Group: Story = {
170
+ render: () => ({
171
+ components: { UiChip },
172
+ setup() {
173
+ const firstSelected = ref(true);
174
+ const secondSelected = ref(false);
175
+ const removableSelected = ref(true);
176
+
177
+ return { firstSelected, secondSelected, removableSelected, TagIcon, InfoIcon };
178
+ },
179
+ template: `
180
+ <div class="flex max-w-[620px] flex-col gap-3">
181
+ <p class="text-sm text-content-on-surface-muted">
182
+ Interactive group example inspired by the Figma chips group. Use Tab to verify focus order.
183
+ </p>
184
+
185
+ <div class="flex flex-wrap items-center gap-2">
186
+ <UiChip v-model="firstSelected" variant="selectable" :start-icon="TagIcon">
187
+ Data Analysis
188
+ </UiChip>
189
+
190
+ <UiChip v-model="secondSelected" variant="selectable">
191
+ Report Generation
192
+ </UiChip>
193
+
194
+ <UiChip variant="removable" :start-icon="InfoIcon">Workflow Automation</UiChip>
195
+
196
+ <UiChip v-model="removableSelected" variant="removable" selectable>
197
+ Add
198
+ </UiChip>
199
+ </div>
200
+ </div>
201
+ `,
202
+ }),
203
+ };
204
+
205
+ /**
206
+ * Focus order for interactive chips:
207
+ * selectable chip -> removable chip action -> removable chip dismiss action.
208
+ */
209
+ export const FocusOrderInteractive: Story = {
210
+ render: () => ({
211
+ components: { UiChip },
212
+ setup() {
213
+ const first = ref(false);
214
+ const second = ref(true);
215
+ return { first, second, TagIcon };
216
+ },
217
+ template: `
218
+ <div class="flex max-w-[620px] flex-col gap-3">
219
+ <p class="text-sm text-content-on-surface-muted">
220
+ Press Tab to verify focus order from left to right and top to bottom.
221
+ </p>
222
+
223
+ <div class="flex flex-wrap items-center gap-2">
224
+ <UiChip v-model="first" variant="selectable" :start-icon="TagIcon">
225
+ Focus 1
226
+ </UiChip>
227
+
228
+ <UiChip v-model="second" variant="removable" selectable :start-icon="TagIcon">
229
+ Focus 2 and 3
230
+ </UiChip>
231
+
232
+ <UiChip variant="removable" :start-icon="TagIcon">
233
+ Focus 4
234
+ </UiChip>
235
+ </div>
236
+ </div>
237
+ `,
238
+ }),
239
+ };
@@ -0,0 +1,128 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { cn } from '@/lib/utils';
4
+ import { UiIcon } from '@/components/UiIcon';
5
+ import type { UiChipProps } from './types';
6
+
7
+ defineOptions({
8
+ name: 'UiChip',
9
+ });
10
+
11
+ const props = withDefaults(defineProps<UiChipProps>(), {
12
+ variant: 'static',
13
+ selectable: false,
14
+ disabled: false,
15
+ removeLabel: 'Remove chip',
16
+ });
17
+
18
+ const emits = defineEmits<{
19
+ (e: 'remove'): void;
20
+ }>();
21
+
22
+ const modelValue = defineModel<boolean>({ default: false });
23
+
24
+ const isRemovable = computed(() => props.variant === 'removable');
25
+ const isSelectable = computed(
26
+ () => props.variant === 'selectable' || (props.variant === 'removable' && props.selectable),
27
+ );
28
+ const isSelected = computed(() => isSelectable.value && modelValue.value);
29
+
30
+ function toggleSelected() {
31
+ if (!isSelectable.value || props.disabled) {
32
+ return;
33
+ }
34
+
35
+ modelValue.value = !modelValue.value;
36
+ }
37
+
38
+ function removeChip(event: MouseEvent) {
39
+ event.stopPropagation();
40
+ if (props.disabled) {
41
+ return;
42
+ }
43
+ emits('remove');
44
+ }
45
+
46
+ function handleRemovablePrimaryAction() {
47
+ if (isSelectable.value) {
48
+ toggleSelected();
49
+ }
50
+ }
51
+
52
+ const chipBaseClass = computed(() =>
53
+ cn(
54
+ 'inline-flex items-center rounded-full border border-border-button-outlined bg-background-surface-secondary text-content-on-accent-soft text-xs font-medium transition-[color,background-color,border-color]',
55
+ 'data-[selected=true]:bg-background-accent-soft data-[selected=true]:border-accent-default',
56
+ 'data-[selected=true]:hover:bg-background-accent-soft data-[selected=true]:hover:border-accent-default',
57
+ props.disabled && 'opacity-50',
58
+ ),
59
+ );
60
+
61
+ const standaloneChipClass = computed(() =>
62
+ cn(
63
+ chipBaseClass.value,
64
+ 'h-7 gap-1.5 px-3',
65
+ isSelectable.value &&
66
+ 'cursor-pointer hover:bg-background-surface-secondary hover:border-border-button-outlined focus-visible:outline-none focus-visible:border-border-surface-focus-ring disabled:pointer-events-none',
67
+ ),
68
+ );
69
+
70
+ const removableChipClass = computed(() =>
71
+ cn(
72
+ chipBaseClass.value,
73
+ 'h-7 gap-0.5 pl-3 pr-1',
74
+ 'focus-within:border-border-surface-focus-ring',
75
+ ),
76
+ );
77
+ </script>
78
+
79
+ <template>
80
+ <span
81
+ v-if="!isRemovable && !isSelectable"
82
+ data-slot="chip"
83
+ :aria-disabled="props.disabled ? 'true' : undefined"
84
+ :class="standaloneChipClass"
85
+ >
86
+ <component :is="props.startIcon" v-if="props.startIcon" class="size-3.5 shrink-0" />
87
+ <slot />
88
+ </span>
89
+
90
+ <button
91
+ v-else-if="!isRemovable"
92
+ data-slot="chip"
93
+ :data-selected="isSelected"
94
+ :disabled="props.disabled"
95
+ :aria-pressed="isSelected ? 'true' : 'false'"
96
+ type="button"
97
+ :class="standaloneChipClass"
98
+ @click="toggleSelected"
99
+ >
100
+ <component :is="props.startIcon" v-if="props.startIcon" class="size-3.5 shrink-0" />
101
+ <slot />
102
+ </button>
103
+
104
+ <div v-else data-slot="chip" :data-selected="isSelected" :class="removableChipClass">
105
+ <button
106
+ type="button"
107
+ class="inline-flex min-w-0 items-center gap-1.5 rounded-full outline-none"
108
+ :class="isSelectable ? 'cursor-pointer' : 'cursor-default'"
109
+ :disabled="props.disabled"
110
+ :aria-pressed="isSelectable ? (isSelected ? 'true' : 'false') : undefined"
111
+ @click="handleRemovablePrimaryAction"
112
+ >
113
+ <component :is="props.startIcon" v-if="props.startIcon" class="size-3.5 shrink-0" />
114
+ <slot />
115
+ </button>
116
+
117
+ <button
118
+ data-slot="chip-remove"
119
+ type="button"
120
+ class="inline-flex size-5 items-center justify-center rounded-full outline-none hover:bg-hover-default focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none"
121
+ :aria-label="props.removeLabel"
122
+ :disabled="props.disabled"
123
+ @click="removeChip"
124
+ >
125
+ <UiIcon name="x" :size="12" class="text-content-on-surface-primary" />
126
+ </button>
127
+ </div>
128
+ </template>
@@ -0,0 +1,102 @@
1
+ import { render } from '@testing-library/vue';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { describe, expect, test } from 'vitest';
4
+ import { MockIcon } from '@/__tests__/test-utils';
5
+ import UiChip from '../UiChip.vue';
6
+
7
+ describe('UiChip', () => {
8
+ test('renders static chip content', () => {
9
+ const { getByText } = render(UiChip, {
10
+ props: { variant: 'static' },
11
+ slots: { default: 'Data Analysis' },
12
+ });
13
+
14
+ expect(getByText('Data Analysis')).toBeInTheDocument();
15
+ });
16
+
17
+ test('renders start icon when provided', () => {
18
+ const { getByTestId } = render(UiChip, {
19
+ props: {
20
+ variant: 'static',
21
+ startIcon: MockIcon,
22
+ },
23
+ slots: { default: 'Chip' },
24
+ });
25
+
26
+ expect(getByTestId('mock-icon')).toBeInTheDocument();
27
+ });
28
+
29
+ test('toggles selected state for selectable chips', async () => {
30
+ const user = userEvent.setup();
31
+ const { getByRole } = render(UiChip, {
32
+ props: { variant: 'selectable' },
33
+ slots: { default: 'Selectable chip' },
34
+ });
35
+
36
+ const chipButton = getByRole('button');
37
+
38
+ expect(chipButton).toHaveAttribute('aria-pressed', 'false');
39
+ await user.click(chipButton);
40
+ expect(chipButton).toHaveAttribute('aria-pressed', 'true');
41
+ });
42
+
43
+ test('renders remove button for removable chips', () => {
44
+ const { getByRole } = render(UiChip, {
45
+ props: { variant: 'removable' },
46
+ slots: { default: 'Removable chip' },
47
+ });
48
+
49
+ expect(getByRole('button', { name: 'Removable chip' })).toBeInTheDocument();
50
+ expect(getByRole('button', { name: 'Remove chip' })).toBeInTheDocument();
51
+ });
52
+
53
+ test('tabs focus from chip body to remove button for removable chips', async () => {
54
+ const user = userEvent.setup();
55
+ const { getByRole } = render(UiChip, {
56
+ props: { variant: 'removable' },
57
+ slots: { default: 'Removable chip' },
58
+ });
59
+
60
+ const chipBodyButton = getByRole('button', { name: 'Removable chip' });
61
+ const removeButton = getByRole('button', { name: 'Remove chip' });
62
+
63
+ await user.tab();
64
+ expect(chipBodyButton).toHaveFocus();
65
+
66
+ await user.tab();
67
+ expect(removeButton).toHaveFocus();
68
+ });
69
+
70
+ test('emits remove when remove button is clicked', async () => {
71
+ const user = userEvent.setup();
72
+ const { getByRole, emitted } = render(UiChip, {
73
+ props: { variant: 'removable' },
74
+ slots: { default: 'Removable chip' },
75
+ });
76
+
77
+ await user.click(getByRole('button', { name: 'Remove chip' }));
78
+
79
+ expect(emitted('remove')).toHaveLength(1);
80
+ });
81
+
82
+ test('disables interactions when disabled', async () => {
83
+ const user = userEvent.setup();
84
+ const { getByRole, emitted } = render(UiChip, {
85
+ props: {
86
+ variant: 'removable',
87
+ selectable: true,
88
+ disabled: true,
89
+ },
90
+ slots: { default: 'Disabled chip' },
91
+ });
92
+
93
+ const selectableButton = getByRole('button', { name: 'Disabled chip' });
94
+ const removeButton = getByRole('button', { name: 'Remove chip' });
95
+
96
+ expect(selectableButton).toBeDisabled();
97
+ expect(removeButton).toBeDisabled();
98
+
99
+ await user.click(removeButton);
100
+ expect(emitted('remove')).toBeUndefined();
101
+ });
102
+ });
@@ -0,0 +1,2 @@
1
+ export { default as UiChip } from './UiChip.vue';
2
+ export type * from './types';
@@ -0,0 +1,50 @@
1
+ import { Component } from 'vue';
2
+
3
+ export type UiChipVariant = 'static' | 'selectable' | 'removable';
4
+
5
+ /**
6
+ * Compact element used to represent a choice, tag, filter, or action.
7
+ * Static and selectable chips share the same base styling, while removable chips
8
+ * expose an additional dismiss action.
9
+ * @category Form Inputs
10
+ * @useCases filters, selected options, tags, quick actions
11
+ * @keywords chip, tag, filter, removable, selectable, dismiss
12
+ * @related UiBadge, UiTagsInput, UiToggle
13
+ */
14
+ export interface UiChipProps {
15
+ /**
16
+ * Chip behavior variant.
17
+ * @default 'static'
18
+ */
19
+ variant?: UiChipVariant;
20
+
21
+ /**
22
+ * Enables selection for the removable variant.
23
+ * Has no effect for `static` and `selectable` variants.
24
+ * @default false
25
+ */
26
+ selectable?: boolean;
27
+
28
+ /**
29
+ * Controlled selected state for interactive chips.
30
+ * Use with `v-model`.
31
+ */
32
+ modelValue?: boolean;
33
+
34
+ /**
35
+ * Whether the chip is disabled.
36
+ * @default false
37
+ */
38
+ disabled?: boolean;
39
+
40
+ /**
41
+ * Optional icon component displayed before the label.
42
+ */
43
+ startIcon?: Component;
44
+
45
+ /**
46
+ * Accessible label for the remove button.
47
+ * @default 'Remove chip'
48
+ */
49
+ removeLabel?: string;
50
+ }