@proteos/ui 0.31.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 +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { AttributeType } from '../attribute-type-icon'
|
|
2
|
+
import type { BadgeVariants } from '../badge/badge'
|
|
3
|
+
|
|
4
|
+
/* =========================================================================
|
|
5
|
+
RecordFilter — types
|
|
6
|
+
|
|
7
|
+
The lib speaks a normalized "field schema" dialect (`FilterField[]`) and
|
|
8
|
+
a canonical filter state (`FilterGroup`). Consumers are responsible for
|
|
9
|
+
mapping their domain entity into `FilterField[]` (e.g. flattening nested
|
|
10
|
+
objects, projecting enum options) and for serializing `FilterGroup` to
|
|
11
|
+
their backend's query format. The lib never touches the SDK or the
|
|
12
|
+
record-service URL convention directly.
|
|
13
|
+
========================================================================= */
|
|
14
|
+
|
|
15
|
+
/* `FilterFieldType` is the subset of the lib-wide `AttributeType` that can
|
|
16
|
+
sit on a leaf filter row. `'object'` is excluded because objects are
|
|
17
|
+
opaque containers — consumers flatten them into dot-paths
|
|
18
|
+
(`'address.street'`) before handing them to the filter, so an object type
|
|
19
|
+
never appears here. Deriving from `AttributeType` keeps the icon mapping
|
|
20
|
+
and the filter type catalogue in lockstep. */
|
|
21
|
+
export type FilterFieldType = Exclude<AttributeType, 'object'>
|
|
22
|
+
|
|
23
|
+
export type FilterFieldOption = {
|
|
24
|
+
value: string
|
|
25
|
+
label: string
|
|
26
|
+
/** Badge color register, used when this option is rendered as a pill. */
|
|
27
|
+
variant?: BadgeVariants['variant']
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type FilterField = {
|
|
31
|
+
/** Dot-path to the attribute, e.g. `'owner.email'`. Stable identifier. */
|
|
32
|
+
path: string
|
|
33
|
+
/** Full nested label, e.g. `'Owner / Email'`. Used in pickers. */
|
|
34
|
+
label: string
|
|
35
|
+
/** Leaf label only, e.g. `'Email'`. Used in chips. */
|
|
36
|
+
shortLabel: string
|
|
37
|
+
/** Parent label for breadcrumb in chips, e.g. `'Owner'`. Omitted for top-level. */
|
|
38
|
+
parentLabel?: string
|
|
39
|
+
type: FilterFieldType
|
|
40
|
+
/** Required when `type === 'enum'`; ignored otherwise. */
|
|
41
|
+
options?: ReadonlyArray<FilterFieldOption>
|
|
42
|
+
/** Hint for `<Input type=…>`. Defaults derived from `type`. */
|
|
43
|
+
inputType?: 'text' | 'number' | 'date' | 'datetime' | 'email'
|
|
44
|
+
/**
|
|
45
|
+
* Relationship metadata for fields that traverse a foreign key into a
|
|
46
|
+
* related entity. Consumers that auto-emit JOINs (e.g. the report
|
|
47
|
+
* builder) read this to determine how to join the related table; the
|
|
48
|
+
* core record-filter UI just renders these like any other field.
|
|
49
|
+
*
|
|
50
|
+
* Path convention: `<attribute>.<relatedFieldPath>`, e.g.
|
|
51
|
+
* `'createdBy.firstName'` or `'accountId.address.city'`. The local FK
|
|
52
|
+
* attribute name is the prefix (not the related entity slug) so multiple
|
|
53
|
+
* relationships to the same target stay distinguishable.
|
|
54
|
+
*/
|
|
55
|
+
relation?: FilterFieldRelation
|
|
56
|
+
/**
|
|
57
|
+
* LUM-34: pointer to the page-layout control that drives editor + chip
|
|
58
|
+
* rendering. When set, the consumer's `renderValueInput` dispatches via
|
|
59
|
+
* the control registry (`registry[controlSlug].Filter`) instead of the
|
|
60
|
+
* lib's built-in type-switch. The lib treats this as opaque metadata —
|
|
61
|
+
* it never touches the registry itself.
|
|
62
|
+
*/
|
|
63
|
+
controlSlug?: string
|
|
64
|
+
/**
|
|
65
|
+
* LUM-34: the raw SDK `Attribute` for this field, threaded through to
|
|
66
|
+
* the descriptor's `Filter` / `Display` components which need full
|
|
67
|
+
* type+meta context (enum values, relation target, etc.). Typed
|
|
68
|
+
* `unknown` because the lib has no `@proteos/sdk` dependency;
|
|
69
|
+
* consumers cast back to `Attribute` in their renderer.
|
|
70
|
+
*/
|
|
71
|
+
attribute?: unknown
|
|
72
|
+
/**
|
|
73
|
+
* LUM-34: per-field operator allowlist. When set, the operator picker
|
|
74
|
+
* uses this list verbatim instead of the per-type default in
|
|
75
|
+
* `operatorsForType(type)`. Lets the registry's per-control overrides
|
|
76
|
+
* (e.g. `password` → `[]`) flow through to the picker without coupling
|
|
77
|
+
* the lib to the registry.
|
|
78
|
+
*/
|
|
79
|
+
filterOperators?: ReadonlyArray<ComparisonOperator>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type FilterFieldRelation = {
|
|
83
|
+
/** Local FK attribute on this entity, e.g. `'accountId'`. Used as JOIN alias. */
|
|
84
|
+
attribute: string
|
|
85
|
+
/** Slug of the related entity table, e.g. `'account'`. */
|
|
86
|
+
relatedEntitySlug: string
|
|
87
|
+
/** Attribute on the related entity to join against, typically `'id'`. */
|
|
88
|
+
relatedAttribute: string
|
|
89
|
+
/** Dot-path of the field on the related entity, e.g. `'name'` or `'address.city'`. */
|
|
90
|
+
relatedFieldPath: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* =========================================================================
|
|
94
|
+
Filter state — mirrors `@proteos/sdk`'s FilterGroup/FilterElement so app
|
|
95
|
+
and lib share one canonical shape. Re-declared locally so the lib has no
|
|
96
|
+
SDK dependency; callers can `as` between them since the structures match.
|
|
97
|
+
========================================================================= */
|
|
98
|
+
|
|
99
|
+
export type ComparisonOperator =
|
|
100
|
+
| 'eq'
|
|
101
|
+
| 'gt'
|
|
102
|
+
| 'lt'
|
|
103
|
+
| 'gte'
|
|
104
|
+
| 'lte'
|
|
105
|
+
| 'ne'
|
|
106
|
+
| 'in'
|
|
107
|
+
| 'not_in'
|
|
108
|
+
| 'contains'
|
|
109
|
+
| 'starts_with'
|
|
110
|
+
| 'ends_with'
|
|
111
|
+
| 'empty'
|
|
112
|
+
| 'not_empty'
|
|
113
|
+
|
|
114
|
+
export type LogicalOperator = 'and' | 'or'
|
|
115
|
+
|
|
116
|
+
export type FilterValue = string | string[] | boolean | null
|
|
117
|
+
|
|
118
|
+
export type FilterElement = {
|
|
119
|
+
field: string
|
|
120
|
+
operator: ComparisonOperator
|
|
121
|
+
/**
|
|
122
|
+
* In-memory value shape. Richer than the SDK's `string`-only `value`
|
|
123
|
+
* because the UI needs structured arrays for `in`/`not_in` and booleans
|
|
124
|
+
* for boolean filters. App-level serializers collapse this back to the
|
|
125
|
+
* SDK's wire format (pipe-joined strings, etc.) at the boundary.
|
|
126
|
+
*/
|
|
127
|
+
value: FilterValue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type FilterGroup = {
|
|
131
|
+
logicalOperator: LogicalOperator
|
|
132
|
+
elements?: FilterElement[]
|
|
133
|
+
groups?: FilterGroup[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* =========================================================================
|
|
137
|
+
Operator descriptor — one row of the catalogue.
|
|
138
|
+
========================================================================= */
|
|
139
|
+
|
|
140
|
+
export type OperatorArity = 0 | 1 | 'n'
|
|
141
|
+
|
|
142
|
+
export type OperatorDescriptor = {
|
|
143
|
+
id: ComparisonOperator
|
|
144
|
+
/** Human label shown in the operator picker. */
|
|
145
|
+
label: string
|
|
146
|
+
/** Number of values the operator takes. */
|
|
147
|
+
arity: OperatorArity
|
|
148
|
+
/** Field types this operator applies to. */
|
|
149
|
+
types: ReadonlyArray<FilterFieldType>
|
|
150
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { SegmentedControl } from './segmented-control'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof SegmentedControl> = {
|
|
7
|
+
title: 'Components/SegmentedControl',
|
|
8
|
+
component: SegmentedControl,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof SegmentedControl>
|
|
14
|
+
|
|
15
|
+
export const AndOrJoiner: Story = {
|
|
16
|
+
render: () => {
|
|
17
|
+
const [value, setValue] = useState<'and' | 'or'>('and')
|
|
18
|
+
return (
|
|
19
|
+
<SegmentedControl
|
|
20
|
+
aria-label="Joiner"
|
|
21
|
+
options={[
|
|
22
|
+
{ id: 'and', label: 'AND' },
|
|
23
|
+
{ id: 'or', label: 'OR' },
|
|
24
|
+
]}
|
|
25
|
+
value={value}
|
|
26
|
+
onChange={setValue}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const BooleanTrueFalse: Story = {
|
|
33
|
+
render: () => {
|
|
34
|
+
const [value, setValue] = useState<'true' | 'false'>('true')
|
|
35
|
+
return (
|
|
36
|
+
<SegmentedControl
|
|
37
|
+
size="md"
|
|
38
|
+
aria-label="Boolean"
|
|
39
|
+
options={[
|
|
40
|
+
{ id: 'true', label: 'true' },
|
|
41
|
+
{ id: 'false', label: 'false' },
|
|
42
|
+
]}
|
|
43
|
+
value={value}
|
|
44
|
+
onChange={setValue}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ThreeSegments: Story = {
|
|
51
|
+
render: () => {
|
|
52
|
+
const [value, setValue] = useState<'compact' | 'cozy' | 'comfy'>('cozy')
|
|
53
|
+
return (
|
|
54
|
+
<SegmentedControl
|
|
55
|
+
size="md"
|
|
56
|
+
aria-label="Density"
|
|
57
|
+
options={[
|
|
58
|
+
{ id: 'compact', label: 'Compact' },
|
|
59
|
+
{ id: 'cozy', label: 'Cozy' },
|
|
60
|
+
{ id: 'comfy', label: 'Comfy' },
|
|
61
|
+
]}
|
|
62
|
+
value={value}
|
|
63
|
+
onChange={setValue}
|
|
64
|
+
/>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { SegmentedControl } from './segmented-control'
|
|
7
|
+
|
|
8
|
+
function Harness({ onChange }: { onChange?: (v: 'and' | 'or') => void }) {
|
|
9
|
+
const [value, setValue] = useState<'and' | 'or'>('and')
|
|
10
|
+
return (
|
|
11
|
+
<SegmentedControl
|
|
12
|
+
aria-label="Joiner"
|
|
13
|
+
options={[
|
|
14
|
+
{ id: 'and', label: 'AND' },
|
|
15
|
+
{ id: 'or', label: 'OR' },
|
|
16
|
+
]}
|
|
17
|
+
value={value}
|
|
18
|
+
onChange={(next) => {
|
|
19
|
+
setValue(next)
|
|
20
|
+
onChange?.(next)
|
|
21
|
+
}}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('SegmentedControl', () => {
|
|
27
|
+
it('renders options as a radiogroup', () => {
|
|
28
|
+
render(<Harness />)
|
|
29
|
+
expect(screen.getByRole('radiogroup', { name: 'Joiner' })).toBeInTheDocument()
|
|
30
|
+
expect(screen.getAllByRole('radio')).toHaveLength(2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reflects the controlled value', () => {
|
|
34
|
+
render(<Harness />)
|
|
35
|
+
expect(screen.getByRole('radio', { name: 'AND' })).toBeChecked()
|
|
36
|
+
expect(screen.getByRole('radio', { name: 'OR' })).not.toBeChecked()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('changes value on click', async () => {
|
|
40
|
+
const user = userEvent.setup()
|
|
41
|
+
const onChange = vi.fn()
|
|
42
|
+
render(<Harness onChange={onChange} />)
|
|
43
|
+
await user.click(screen.getByRole('radio', { name: 'OR' }))
|
|
44
|
+
expect(onChange).toHaveBeenLastCalledWith('or')
|
|
45
|
+
expect(screen.getByRole('radio', { name: 'OR' })).toBeChecked()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Key, ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Radio as AriaRadio,
|
|
4
|
+
RadioGroup as AriaRadioGroup,
|
|
5
|
+
type RadioGroupProps as AriaRadioGroupProps,
|
|
6
|
+
} from 'react-aria-components'
|
|
7
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../../lib/cn'
|
|
10
|
+
|
|
11
|
+
/* =========================================================================
|
|
12
|
+
SegmentedControl — small horizontal exclusive-choice group with pill
|
|
13
|
+
chrome. Wraps RAC RadioGroup so keyboard nav and focus management are
|
|
14
|
+
correct (arrow keys move between segments). Used for compact toggles
|
|
15
|
+
like AND/OR joiners and true/false booleans where a full RadioGroup
|
|
16
|
+
feels heavy.
|
|
17
|
+
========================================================================= */
|
|
18
|
+
|
|
19
|
+
export const segmentedControlStyles = tv({
|
|
20
|
+
slots: {
|
|
21
|
+
// `w-fit` keeps the group content-width even as a flex/grid item under a
|
|
22
|
+
// stretching parent (e.g. a `flex flex-col` card body, whose default
|
|
23
|
+
// align-items:stretch would otherwise pull it to full width). Consumers
|
|
24
|
+
// can still opt into full width by passing `w-full` via className.
|
|
25
|
+
group: 'inline-flex w-fit rounded-sm border border-rule-strong bg-surface p-0.5 gap-0.5',
|
|
26
|
+
segment: [
|
|
27
|
+
'inline-flex items-center justify-center cursor-pointer',
|
|
28
|
+
'font-mono tracking-[0.04em] rounded-[3px] outline-none select-none',
|
|
29
|
+
'transition-[background-color,color] duration-150',
|
|
30
|
+
'data-[hovered]:text-ink',
|
|
31
|
+
'data-[selected]:bg-ink data-[selected]:text-surface',
|
|
32
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
33
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
variants: {
|
|
37
|
+
size: {
|
|
38
|
+
sm: { segment: 'px-2 py-0.5 text-[10.5px] tracking-[0.08em]' },
|
|
39
|
+
md: { segment: 'px-2.5 py-1 text-[12px]' },
|
|
40
|
+
},
|
|
41
|
+
tone: {
|
|
42
|
+
// Default: ink fill on selected.
|
|
43
|
+
default: { segment: 'text-ink-3' },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: { size: 'sm', tone: 'default' },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export type SegmentedControlVariants = VariantProps<typeof segmentedControlStyles>
|
|
50
|
+
|
|
51
|
+
export type SegmentedControlOption<T extends Key = Key> = {
|
|
52
|
+
id: T
|
|
53
|
+
label: ReactNode
|
|
54
|
+
isDisabled?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SegmentedControlProps<T extends Key = Key> = Omit<
|
|
58
|
+
AriaRadioGroupProps,
|
|
59
|
+
'children' | 'value' | 'onChange'
|
|
60
|
+
> & {
|
|
61
|
+
options: ReadonlyArray<SegmentedControlOption<T>>
|
|
62
|
+
value: T
|
|
63
|
+
onChange: (value: T) => void
|
|
64
|
+
size?: SegmentedControlVariants['size']
|
|
65
|
+
/** Focus the selected segment on mount. RAC's RadioGroup doesn't surface
|
|
66
|
+
* `autoFocus` itself — it's a per-Radio prop — so we apply it to the
|
|
67
|
+
* Radio whose value matches `value`. */
|
|
68
|
+
autoFocus?: boolean
|
|
69
|
+
/** Visual override for the group wrapper. */
|
|
70
|
+
className?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function SegmentedControl<T extends Key>({
|
|
74
|
+
options,
|
|
75
|
+
value,
|
|
76
|
+
onChange,
|
|
77
|
+
size,
|
|
78
|
+
autoFocus = false,
|
|
79
|
+
className,
|
|
80
|
+
orientation = 'horizontal',
|
|
81
|
+
...props
|
|
82
|
+
}: SegmentedControlProps<T>) {
|
|
83
|
+
const styles = segmentedControlStyles({ size })
|
|
84
|
+
return (
|
|
85
|
+
<AriaRadioGroup
|
|
86
|
+
{...props}
|
|
87
|
+
orientation={orientation}
|
|
88
|
+
value={String(value)}
|
|
89
|
+
onChange={(next) => onChange(next as T)}
|
|
90
|
+
className={cn(styles.group(), className)}
|
|
91
|
+
>
|
|
92
|
+
{options.map((option) => (
|
|
93
|
+
<AriaRadio
|
|
94
|
+
key={String(option.id)}
|
|
95
|
+
value={String(option.id)}
|
|
96
|
+
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
97
|
+
{...(autoFocus && option.id === value ? { autoFocus: true } : {})}
|
|
98
|
+
className={styles.segment()}
|
|
99
|
+
>
|
|
100
|
+
{option.label}
|
|
101
|
+
</AriaRadio>
|
|
102
|
+
))}
|
|
103
|
+
</AriaRadioGroup>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
|
|
3
|
+
import { Select } from './select'
|
|
4
|
+
|
|
5
|
+
const industries = [
|
|
6
|
+
{ id: 'freight', label: 'Freight & logistics' },
|
|
7
|
+
{ id: 'lending', label: 'Specialty lending' },
|
|
8
|
+
{ id: 'clinical', label: 'Clinical trials' },
|
|
9
|
+
{ id: 'services', label: 'Professional services' },
|
|
10
|
+
{ id: 'insurance', label: 'Insurance MGA' },
|
|
11
|
+
{ id: 'b2b', label: 'B2B SaaS' },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof Select> = {
|
|
15
|
+
title: 'Components/Select',
|
|
16
|
+
component: Select,
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
args: {
|
|
19
|
+
label: 'Industry',
|
|
20
|
+
placeholder: 'Pick an industry',
|
|
21
|
+
options: industries,
|
|
22
|
+
},
|
|
23
|
+
argTypes: {
|
|
24
|
+
isDisabled: { control: { type: 'boolean' } },
|
|
25
|
+
isInvalid: { control: { type: 'boolean' } },
|
|
26
|
+
},
|
|
27
|
+
decorators: [
|
|
28
|
+
(Story) => (
|
|
29
|
+
<div className="w-[440px]">
|
|
30
|
+
<Story />
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default meta
|
|
37
|
+
type Story = StoryObj<typeof Select>
|
|
38
|
+
|
|
39
|
+
export const Playground: Story = {}
|
|
40
|
+
|
|
41
|
+
export const WithDescription: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
description: 'Proteos pre-seeds the entity model from this.',
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Invalid: Story = {
|
|
48
|
+
args: {
|
|
49
|
+
isInvalid: true,
|
|
50
|
+
errorMessage: 'Select an industry to continue.',
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Disabled: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
defaultSelectedKey: 'freight',
|
|
57
|
+
isDisabled: true,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const AllStates: Story = {
|
|
62
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
63
|
+
render: () => (
|
|
64
|
+
<div className="grid gap-5 w-[440px]">
|
|
65
|
+
<Select label="Rest" options={industries} />
|
|
66
|
+
<Select label="Selected" options={industries} defaultSelectedKey="lending" />
|
|
67
|
+
<Select
|
|
68
|
+
label="Invalid"
|
|
69
|
+
options={industries}
|
|
70
|
+
isInvalid
|
|
71
|
+
errorMessage="Select an industry to continue."
|
|
72
|
+
/>
|
|
73
|
+
<Select label="Disabled" options={industries} defaultSelectedKey="clinical" isDisabled />
|
|
74
|
+
</div>
|
|
75
|
+
),
|
|
76
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { Select } from './select'
|
|
6
|
+
|
|
7
|
+
const industries = [
|
|
8
|
+
{ id: 'freight', label: 'Freight & logistics' },
|
|
9
|
+
{ id: 'lending', label: 'Specialty lending' },
|
|
10
|
+
{ id: 'clinical', label: 'Clinical trials' },
|
|
11
|
+
] as const
|
|
12
|
+
|
|
13
|
+
describe('Select', () => {
|
|
14
|
+
it('renders a labelled select trigger', () => {
|
|
15
|
+
render(<Select label="Industry" options={[...industries]} />)
|
|
16
|
+
expect(screen.getByRole('button', { name: /industry/i })).toBeInTheDocument()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('opens the listbox on click and selects an option', async () => {
|
|
20
|
+
const user = userEvent.setup()
|
|
21
|
+
const onSelectionChange = vi.fn()
|
|
22
|
+
render(
|
|
23
|
+
<Select label="Industry" options={[...industries]} onSelectionChange={onSelectionChange} />,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
await user.click(screen.getByRole('button', { name: /industry/i }))
|
|
27
|
+
const listbox = await screen.findByRole('listbox')
|
|
28
|
+
expect(listbox).toBeInTheDocument()
|
|
29
|
+
|
|
30
|
+
await user.click(screen.getByRole('option', { name: 'Specialty lending' }))
|
|
31
|
+
expect(onSelectionChange).toHaveBeenCalledWith('lending')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('supports keyboard navigation', async () => {
|
|
35
|
+
const user = userEvent.setup()
|
|
36
|
+
render(<Select label="Industry" options={[...industries]} />)
|
|
37
|
+
|
|
38
|
+
await user.tab()
|
|
39
|
+
expect(screen.getByRole('button', { name: /industry/i })).toHaveFocus()
|
|
40
|
+
|
|
41
|
+
await user.keyboard('{Enter}')
|
|
42
|
+
expect(await screen.findByRole('listbox')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('shows placeholder when nothing is selected', () => {
|
|
46
|
+
render(<Select label="Industry" placeholder="Pick one" options={[...industries]} />)
|
|
47
|
+
const trigger = screen.getByRole('button', { name: /industry/i })
|
|
48
|
+
expect(trigger).toHaveTextContent('Pick one')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders a controlled selection', () => {
|
|
52
|
+
render(<Select label="Industry" options={[...industries]} selectedKey="clinical" />)
|
|
53
|
+
const trigger = screen.getByRole('button', { name: /industry/i })
|
|
54
|
+
expect(trigger).toHaveTextContent('Clinical trials')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Select as AriaSelect,
|
|
4
|
+
Button as AriaSelectButton,
|
|
5
|
+
type SelectProps as AriaSelectProps,
|
|
6
|
+
FieldError,
|
|
7
|
+
type Key,
|
|
8
|
+
Label,
|
|
9
|
+
ListBox,
|
|
10
|
+
ListBoxItem,
|
|
11
|
+
type ListBoxItemProps,
|
|
12
|
+
Popover,
|
|
13
|
+
SelectValue,
|
|
14
|
+
Text,
|
|
15
|
+
} from 'react-aria-components'
|
|
16
|
+
import { cn } from '../../lib/cn'
|
|
17
|
+
import {
|
|
18
|
+
type FieldAppearance,
|
|
19
|
+
FieldCaret,
|
|
20
|
+
type FieldSize,
|
|
21
|
+
fieldControlStyles,
|
|
22
|
+
fieldErrorStyles,
|
|
23
|
+
fieldHintStyles,
|
|
24
|
+
fieldLabelStyles,
|
|
25
|
+
} from '../field/field'
|
|
26
|
+
|
|
27
|
+
export type SelectOption<T extends Key = Key> = {
|
|
28
|
+
id: T
|
|
29
|
+
label: ReactNode
|
|
30
|
+
/** Plain-text stand-in for typeahead/accessibility when `label` is a
|
|
31
|
+
* ReactNode. Optional for string labels (RAC derives the text itself). */
|
|
32
|
+
textValue?: string
|
|
33
|
+
description?: ReactNode
|
|
34
|
+
isDisabled?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SelectProps<T extends Key = Key> = Omit<AriaSelectProps<never>, 'children'> & {
|
|
38
|
+
label?: ReactNode
|
|
39
|
+
description?: ReactNode
|
|
40
|
+
errorMessage?: ReactNode
|
|
41
|
+
placeholder?: string
|
|
42
|
+
options: ReadonlyArray<SelectOption<T>>
|
|
43
|
+
/** Optional custom renderer for a ListBoxItem. */
|
|
44
|
+
renderItem?: (option: SelectOption<T>) => ReactNode
|
|
45
|
+
/** Field size. Defaults to `md`. */
|
|
46
|
+
size?: FieldSize
|
|
47
|
+
/** Chrome variant. Defaults to `field`. `cell` flattens the trigger box. */
|
|
48
|
+
appearance?: FieldAppearance
|
|
49
|
+
/** Visual override for the trigger only. */
|
|
50
|
+
triggerClassName?: string
|
|
51
|
+
/** Visual override for the popover — e.g. `min-w-[var(--trigger-width)]
|
|
52
|
+
* w-max` to size to content when the trigger is intrinsic-width. Defaults
|
|
53
|
+
* to matching the trigger width exactly. */
|
|
54
|
+
popoverClassName?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function Select<T extends Key>({
|
|
58
|
+
label,
|
|
59
|
+
description,
|
|
60
|
+
errorMessage,
|
|
61
|
+
placeholder = 'Select…',
|
|
62
|
+
options,
|
|
63
|
+
renderItem,
|
|
64
|
+
size,
|
|
65
|
+
appearance,
|
|
66
|
+
triggerClassName,
|
|
67
|
+
popoverClassName,
|
|
68
|
+
className,
|
|
69
|
+
...props
|
|
70
|
+
}: SelectProps<T>) {
|
|
71
|
+
const styles = fieldControlStyles({ size, appearance })
|
|
72
|
+
return (
|
|
73
|
+
<AriaSelect
|
|
74
|
+
{...props}
|
|
75
|
+
className={(renderProps) =>
|
|
76
|
+
cn(
|
|
77
|
+
'grid grid-cols-1 gap-1.5',
|
|
78
|
+
typeof className === 'function' ? className(renderProps) : className,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
{label ? <Label className={fieldLabelStyles}>{label}</Label> : null}
|
|
83
|
+
<AriaSelectButton
|
|
84
|
+
className={cn(
|
|
85
|
+
styles.box(),
|
|
86
|
+
styles.content(),
|
|
87
|
+
'flex items-center justify-between gap-3 text-left cursor-pointer',
|
|
88
|
+
triggerClassName,
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
<SelectValue className="truncate">
|
|
92
|
+
{({ defaultChildren, isPlaceholder }) =>
|
|
93
|
+
isPlaceholder ? <span className="text-ink-4">{placeholder}</span> : defaultChildren
|
|
94
|
+
}
|
|
95
|
+
</SelectValue>
|
|
96
|
+
<FieldCaret />
|
|
97
|
+
</AriaSelectButton>
|
|
98
|
+
|
|
99
|
+
{description ? (
|
|
100
|
+
<Text slot="description" className={fieldHintStyles}>
|
|
101
|
+
{description}
|
|
102
|
+
</Text>
|
|
103
|
+
) : null}
|
|
104
|
+
{errorMessage && appearance !== 'cell' ? (
|
|
105
|
+
<FieldError className={fieldErrorStyles}>{errorMessage}</FieldError>
|
|
106
|
+
) : null}
|
|
107
|
+
|
|
108
|
+
<Popover
|
|
109
|
+
offset={6}
|
|
110
|
+
className={cn(
|
|
111
|
+
// Match the trigger width exactly (RAC exposes --trigger-width on
|
|
112
|
+
// the Popover when anchored to a Select button).
|
|
113
|
+
'w-[var(--trigger-width)]',
|
|
114
|
+
// Float above modals + full-screen editors (e.g. agent-studio's
|
|
115
|
+
// .as-editor z-1000 / a raised modal overlay), matching Menu's z-[1200].
|
|
116
|
+
'z-[1300]',
|
|
117
|
+
'rounded-md border border-rule bg-surface p-1',
|
|
118
|
+
'shadow-[var(--shadow-raised)]',
|
|
119
|
+
'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
|
|
120
|
+
'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
|
|
121
|
+
popoverClassName,
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
<ListBox className="outline-none max-h-72 overflow-auto">
|
|
125
|
+
{options.map((option) => (
|
|
126
|
+
<SelectItem
|
|
127
|
+
key={option.id}
|
|
128
|
+
id={option.id}
|
|
129
|
+
{...(option.textValue ? { textValue: option.textValue } : {})}
|
|
130
|
+
{...(option.isDisabled ? { isDisabled: true } : {})}
|
|
131
|
+
>
|
|
132
|
+
{renderItem ? renderItem(option) : option.label}
|
|
133
|
+
</SelectItem>
|
|
134
|
+
))}
|
|
135
|
+
</ListBox>
|
|
136
|
+
{options.length === 0 ? (
|
|
137
|
+
<div className={cn('px-3 py-2', fieldHintStyles)}>{placeholder}</div>
|
|
138
|
+
) : null}
|
|
139
|
+
</Popover>
|
|
140
|
+
</AriaSelect>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function SelectItem(props: ListBoxItemProps) {
|
|
145
|
+
return (
|
|
146
|
+
<ListBoxItem
|
|
147
|
+
{...props}
|
|
148
|
+
className={cn(
|
|
149
|
+
'flex items-center justify-between gap-3',
|
|
150
|
+
'rounded-[6px] px-3 py-2 text-[14px] text-ink-2',
|
|
151
|
+
'cursor-pointer outline-none select-none',
|
|
152
|
+
'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
|
|
153
|
+
'data-[focused]:bg-surface-2 data-[focused]:text-ink',
|
|
154
|
+
'data-[selected]:bg-surface-3 data-[selected]:text-ink',
|
|
155
|
+
'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
|
|
156
|
+
)}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
}
|