@flitsmeister/design-system 2.2.22 → 2.2.33

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/README.md CHANGED
@@ -31,11 +31,13 @@ Design tokens are exported as platform-specific files in `tokens/schema_exports/
31
31
  You can import individual Vue components directly from the package:
32
32
 
33
33
  ```js
34
- import { fmxButton, fmxInput } from "@flitsmeister/design-system";
34
+ import { fmxButton, fmxInput, fmxDropdown } from "@flitsmeister/design-system";
35
35
  ```
36
36
 
37
37
  These are standard Vue 3 single-file components. Register and use them in your app as you would any other Vue component.
38
38
 
39
+ **fmxDropdown** is a v-model select component. Pass `options` as an array of `{ value, label }`, and bind `v-model` to the selected value. Use the `option` slot to customize option rendering. Set `fullScreen` to true for a mobile-friendly modal overlay.
40
+
39
41
  ### 3. Using Icons
40
42
 
41
43
  SVG icon files are available via the `icons/` export path. You can import any icon directly:
@@ -52,7 +54,7 @@ You can also reference icons by their path for use in `<img>`, `<svg>`, or as as
52
54
 
53
55
  ## About the Design System
54
56
 
55
- - Provides Flitsmeister design system colors, typography, buttons, icons, and input components.
57
+ - Provides Flitsmeister design system colors, typography, buttons, icons, input, and dropdown components.
56
58
  - Design tokens are managed and exported from Figma, with automated scripts generating platform-specific outputs (CSS, iOS JSON, Android XML, etc.).
57
59
  - See the `/tokens/schema_exports/` directory for all available exports.
58
60
 
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { default as fmxInput } from './web/components/fmxInput.vue';
2
2
  export { default as fmxButton } from './web/components/fmxButton.vue';
3
+ export { default as fmxDropdown } from './web/components/fmxDropdown.vue';
3
4
 
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@flitsmeister/design-system",
3
- "version": "2.2.22",
3
+ "version": "2.2.33",
4
4
  "description": "Flitsmeister design system and demo site",
5
5
  "main": "index.js",
6
6
  "exports": {
7
7
  ".": "./index.js",
8
8
  "./fmxInput": "./web/components/fmxInput.vue",
9
9
  "./fmxButton": "./web/components/fmxButton.vue",
10
+ "./fmxDropdown": "./web/components/fmxDropdown.vue",
10
11
  "./main.css": "./dist-lib/main.css",
11
12
  "./themes/*": "./tokens/schema_exports/*",
12
13
  "./components/*": "./web/components/*",
@@ -1,5 +1,6 @@
1
1
  <template>
2
2
  <button
3
+ :type="type"
3
4
  :class="buttonClasses"
4
5
  :title="title"
5
6
  :href="href"
@@ -9,23 +10,26 @@
9
10
  :data-buttontype="buttontype"
10
11
  :data-size="size"
11
12
  >
12
- <span
13
+ <Icon
13
14
  v-if="icon && iconPosition === 'prepend'"
14
- class="icon"
15
- >{{ icon }}</span
16
- >
15
+ icon-class="mr-2"
16
+ :name="icon"
17
+ />
17
18
  <slot></slot>
18
- <span
19
+ <Icon
19
20
  v-if="icon && iconPosition === 'append'"
20
- class="icon"
21
- >{{ icon }}</span
22
- >
21
+ icon-class="ml-2"
22
+ :name="icon"
23
+ />
23
24
  </button>
24
25
  </template>
25
26
 
26
27
  <script>
28
+ import Icon from "./Icon.vue";
29
+
27
30
  export default {
28
31
  name: "fmxButton",
32
+ components: { Icon },
29
33
  props: {
30
34
  type: {
31
35
  type: String,
@@ -71,6 +75,10 @@
71
75
  validator: (value) =>
72
76
  ["filled", "text", "outline", "squared"].includes(value),
73
77
  },
78
+ /**
79
+ * Optional icon: symbol id for the SVG sprite.
80
+ * The consuming app must inject an SVG sprite containing symbols with these ids (e.g. via Vite/Webpack sprite plugin).
81
+ */
74
82
  icon: {
75
83
  type: String,
76
84
  default: "",
@@ -259,10 +267,6 @@
259
267
  }
260
268
  }
261
269
 
262
- .icon {
263
- margin: 0 0.5rem;
264
- }
265
-
266
270
  @keyframes horizontal-shaking {
267
271
  0% {
268
272
  transform: translateX(0);
@@ -0,0 +1,226 @@
1
+ <template>
2
+ <div class="fmx-dropdown relative" ref="dropdownRoot">
3
+ <label
4
+ v-if="label"
5
+ class="block px-1 left-2.5 text-content-defaultalt text-lg mb-1"
6
+ >
7
+ {{ label }}
8
+ </label>
9
+ <button
10
+ type="button"
11
+ :disabled="disabled"
12
+ :aria-expanded="isOpen"
13
+ aria-haspopup="listbox"
14
+ class="fmx-dropdown-trigger w-full bg-surface-defaulthighest text-content-default text-lg leading-snug border-2 rounded-2xl px-3 py-3 h-[60px] focus:outline-none focus:border-out hover:border-outline-brand inline-flex items-center justify-between disabled:opacity-50 disabled:cursor-not-allowed"
15
+ :class="[triggerClass, { 'fmx-dropdown-trigger--just-changed': justChanged }]"
16
+ @click="toggle"
17
+ >
18
+ <span class="truncate text-left flex-1">
19
+ {{ selectedLabel }}
20
+ </span>
21
+ <svg
22
+ class="fmx-dropdown-chevron h-5 w-5 flex-shrink-0 ml-2 transition-transform"
23
+ :class="{ 'rotate-180': isOpen }"
24
+ xmlns="http://www.w3.org/2000/svg"
25
+ viewBox="0 0 20 20"
26
+ fill="currentColor"
27
+ aria-hidden="true"
28
+ >
29
+ <path
30
+ fill-rule="evenodd"
31
+ d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
32
+ clip-rule="evenodd"
33
+ />
34
+ </svg>
35
+ </button>
36
+
37
+ <transition name="fmx-dropdown">
38
+ <div
39
+ v-if="isOpen && !fullScreen"
40
+ class="fmx-dropdown-panel absolute left-0 right-0 mt-2 rounded-2xl shadow-lg bg-surface-defaulthighest border-2 border-surface-neutral-default overflow-hidden z-10"
41
+ role="listbox"
42
+ >
43
+ <div class="py-1 max-h-[250px] overflow-y-auto">
44
+ <button
45
+ v-for="opt in options"
46
+ :key="opt.value"
47
+ type="button"
48
+ role="option"
49
+ :aria-selected="opt.value === modelValue"
50
+ class="fmx-dropdown-option block w-full text-left px-4 py-3 text-lg text-content-default hover:bg-surface-defaulthigher focus:bg-surface-defaulthigher focus:outline-none"
51
+ :class="{ 'bg-surface-defaulthigher': opt.value === modelValue }"
52
+ @click="select(opt)"
53
+ >
54
+ <slot name="option" :option="opt" :selected="opt.value === modelValue">
55
+ {{ opt.label }}
56
+ </slot>
57
+ </button>
58
+ </div>
59
+ </div>
60
+ </transition>
61
+
62
+ <Teleport to="body">
63
+ <transition name="fmx-dropdown-modal">
64
+ <div
65
+ v-if="isOpen && fullScreen"
66
+ class="fmx-dropdown-overlay fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm bg-white/30"
67
+ @click.self="close"
68
+ >
69
+ <div
70
+ class="fmx-dropdown-modal-panel bg-surface-defaulthighest rounded-2xl shadow-lg border-2 border-surface-neutral-default overflow-hidden w-[min(300px,90vw)] max-h-[80vh] flex flex-col"
71
+ role="listbox"
72
+ >
73
+ <div class="py-1 overflow-y-auto max-h-[250px]">
74
+ <button
75
+ v-for="opt in options"
76
+ :key="opt.value"
77
+ type="button"
78
+ role="option"
79
+ :aria-selected="opt.value === modelValue"
80
+ class="fmx-dropdown-option block w-full text-left px-4 py-3 text-lg text-content-default hover:bg-surface-defaulthigher focus:bg-surface-defaulthigher focus:outline-none"
81
+ :class="{ 'bg-surface-defaulthigher': opt.value === modelValue }"
82
+ @click="select(opt)"
83
+ >
84
+ <slot name="option" :option="opt" :selected="opt.value === modelValue">
85
+ {{ opt.label }}
86
+ </slot>
87
+ </button>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </transition>
92
+ </Teleport>
93
+ </div>
94
+ </template>
95
+
96
+ <script>
97
+ export default {
98
+ name: 'fmxDropdown',
99
+ props: {
100
+ modelValue: {
101
+ type: [String, Number],
102
+ default: null,
103
+ },
104
+ options: {
105
+ type: Array,
106
+ default: () => [],
107
+ validator(opts) {
108
+ return opts.every((o) => o && typeof o.value !== 'undefined' && typeof o.label === 'string')
109
+ },
110
+ },
111
+ label: {
112
+ type: String,
113
+ default: '',
114
+ },
115
+ placeholder: {
116
+ type: String,
117
+ default: '',
118
+ },
119
+ disabled: {
120
+ type: Boolean,
121
+ default: false,
122
+ },
123
+ triggerClass: {
124
+ type: [String, Array, Object],
125
+ default: '',
126
+ },
127
+ fullScreen: {
128
+ type: Boolean,
129
+ default: false,
130
+ },
131
+ },
132
+ emits: ['update:modelValue'],
133
+ data() {
134
+ return {
135
+ isOpen: false,
136
+ justChanged: false,
137
+ changeTimeout: null,
138
+ }
139
+ },
140
+ computed: {
141
+ selectedLabel() {
142
+ const opt = this.options.find((o) => o.value === this.modelValue)
143
+ return opt ? opt.label : this.placeholder
144
+ },
145
+ },
146
+ methods: {
147
+ toggle() {
148
+ if (this.disabled) return
149
+ this.isOpen = !this.isOpen
150
+ },
151
+ close() {
152
+ this.isOpen = false
153
+ },
154
+ select(opt) {
155
+ const changed = opt.value !== this.modelValue
156
+ this.$emit('update:modelValue', opt.value)
157
+ this.isOpen = false
158
+ if (!changed) return
159
+ if (this.changeTimeout) clearTimeout(this.changeTimeout)
160
+ this.justChanged = true
161
+ this.changeTimeout = setTimeout(() => {
162
+ this.justChanged = false
163
+ this.changeTimeout = null
164
+ }, 200)
165
+ },
166
+ handleClickOutside(e) {
167
+ if (this.$refs.dropdownRoot && !this.$refs.dropdownRoot.contains(e.target)) {
168
+ this.isOpen = false
169
+ }
170
+ },
171
+ },
172
+ mounted() {
173
+ document.addEventListener('click', this.handleClickOutside)
174
+ },
175
+ beforeUnmount() {
176
+ document.removeEventListener('click', this.handleClickOutside)
177
+ if (this.changeTimeout) clearTimeout(this.changeTimeout)
178
+ },
179
+ }
180
+ </script>
181
+
182
+ <style lang="scss" scoped>
183
+ .fmx-dropdown-trigger {
184
+ border-color: var(--color-surface-neutral-default);
185
+ transition: background-color 0.2s ease-out, border-color 0.2s ease-out;
186
+ }
187
+ .fmx-dropdown-trigger--just-changed {
188
+ background-color: var(--color-surface-defaulthigher);
189
+ }
190
+ .fmx-dropdown-panel,
191
+ .fmx-dropdown-modal-panel {
192
+ border-color: var(--color-surface-neutral-default);
193
+ }
194
+ @media (prefers-reduced-motion: reduce) {
195
+ .fmx-dropdown-trigger {
196
+ transition-duration: 0.01ms;
197
+ }
198
+ }
199
+ </style>
200
+
201
+ <style lang="scss">
202
+ .fmx-dropdown-enter-active,
203
+ .fmx-dropdown-leave-active {
204
+ transition: opacity 0.15s ease, transform 0.15s ease;
205
+ }
206
+ .fmx-dropdown-enter-from,
207
+ .fmx-dropdown-leave-to {
208
+ opacity: 0;
209
+ transform: translateY(-4px);
210
+ }
211
+
212
+ .fmx-dropdown-modal-enter-active,
213
+ .fmx-dropdown-modal-leave-active {
214
+ &,
215
+ .fmx-dropdown-modal-panel {
216
+ transition: all 0.2s ease-out;
217
+ }
218
+ }
219
+ .fmx-dropdown-modal-enter-from,
220
+ .fmx-dropdown-modal-leave-to {
221
+ opacity: 0;
222
+ .fmx-dropdown-modal-panel {
223
+ transform: scale(1.05) translateY(-12px);
224
+ }
225
+ }
226
+ </style>
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <div
3
- class="inputgroup w-full max-w-sm min-w-[200px] mb-6"
3
+ class="inputgroup w-full mb-6"
4
4
  :class="[customClass]"
5
5
  :data-inputgroup-type="type"
6
6
  >
@@ -9,41 +9,91 @@
9
9
  :type="type"
10
10
  :value="modelValue"
11
11
  @input="handleInput"
12
+ @focus="handleFocus"
12
13
  @blur="handleBlur"
13
14
  :disabled="disabled"
14
15
  :readonly="readonly"
15
- class="peer w-full bg-surface-defaulthighest text-content-default text-lg leading-snug border-2 rounded-2xl px-3 pt-6 pb-1 transition duration-300 ease focus:outline-none focus:border-out"
16
- :class="inputClass"
16
+ :maxlength="maxLength"
17
+ :autocomplete="autocomplete"
18
+ class="peer w-full bg-surface-defaulthighest text-content-default border-2 transition duration-300 ease focus:outline-none focus:border-out autofill:bg-surface-defaulthighest autofill:text-content-default"
19
+ :class="[
20
+ inputStateClass,
21
+ sizeConfig.input,
22
+ placeholder ? sizeConfig.inputWithPlaceholder : sizeConfig.inputWithoutPlaceholder,
23
+ inputClass,
24
+ ]"
25
+ :style="{ letterSpacing: letterSpacing + 'em' }"
17
26
  />
18
27
  <label
28
+ v-if="placeholder"
19
29
  :class="[
20
- 'absolute px-1 left-2.5 top-3.5 text-content-defaultalt text-lg transition-all transform origin-left pointer-events-none',
21
- labelClass,
30
+ 'absolute px-1 left-2.5 text-content-defaultalt transition-all transform origin-left pointer-events-none',
31
+ sizeConfig.label,
32
+ sizeConfig.labelText,
33
+ sizeConfig.labelFocused,
34
+ labelFilledClass,
22
35
  ]"
23
36
  >
24
37
  {{ placeholder }}
25
38
  </label>
26
39
  <transition name="icon-scale">
27
40
  <span
28
- class="iconwrapper absolute right-3 top-2.5"
29
- :class="iconClass"
41
+ v-if="icon"
42
+ class="iconwrapper absolute"
43
+ :class="[sizeConfig.icon, iconClass]"
30
44
  >
31
45
  <Icon :name="icon" />
32
46
  </span>
33
47
  </transition>
34
48
  </div>
35
49
  <p
36
- v-if="errorMessage"
50
+ v-if="errorMessage && !isFocused"
37
51
  class="text-content-danger text-sm font-bold mt-1"
38
- >
39
- {{ errorMessage }}
40
- </p>
52
+ v-html="errorMessage"
53
+ />
41
54
  </div>
42
55
  </template>
43
56
 
44
57
  <script>
45
58
  import Icon from "./Icon.vue";
46
59
 
60
+ const SIZE_CONFIG = {
61
+ sm: {
62
+ input: "h-10 text-base rounded-2xl px-3 autofill:pt-5 autofill:pb-1",
63
+ inputWithPlaceholder: "pt-5 pb-1",
64
+ inputWithoutPlaceholder: "py-2 flex items-center justify-center",
65
+ label: "top-2.5",
66
+ labelText: "text-base",
67
+ labelFocused:
68
+ "peer-focus:top-[4px] peer-focus:left-2.5 peer-focus:text-xs peer-focus:font-bold peer-autofill:top-[4px] peer-autofill:left-2.5 peer-autofill:text-xs peer-autofill:font-bold",
69
+ labelFilled: "top-[4px] left-2.5 text-xs font-bold",
70
+ icon: "right-2 top-2.5 text-xl leading-none",
71
+ },
72
+ md: {
73
+ input:
74
+ "h-[60px] text-lg leading-snug rounded-2xl px-3 autofill:pt-6 autofill:pb-1",
75
+ inputWithPlaceholder: "pt-6 pb-1",
76
+ inputWithoutPlaceholder: "py-3 flex items-center justify-center",
77
+ label: "top-3.5",
78
+ labelText: "text-lg",
79
+ labelFocused:
80
+ "peer-focus:top-[6px] peer-focus:left-2.5 peer-focus:text-sm peer-focus:font-bold peer-autofill:top-[6px] peer-autofill:left-2.5 peer-autofill:text-sm peer-autofill:font-bold",
81
+ labelFilled: "top-[6px] left-2.5 text-sm font-bold",
82
+ icon: "right-2 top-4 text-2xl leading-none",
83
+ },
84
+ lg: {
85
+ input: "h-14 text-lg rounded-2xl px-4 autofill:pt-7 autofill:pb-1",
86
+ inputWithPlaceholder: "pt-7 pb-1",
87
+ inputWithoutPlaceholder: "py-4 flex items-center justify-center",
88
+ label: "top-4",
89
+ labelText: "text-lg",
90
+ labelFocused:
91
+ "peer-focus:top-[8px] peer-focus:left-2.5 peer-focus:text-sm peer-focus:font-bold peer-autofill:top-[8px] peer-autofill:left-2.5 peer-autofill:text-sm peer-autofill:font-bold",
92
+ labelFilled: "top-[8px] left-2.5 text-sm font-bold",
93
+ icon: "right-3 top-5 text-2xl leading-none",
94
+ },
95
+ };
96
+
47
97
  export default {
48
98
  name: "fmxInput",
49
99
  components: {
@@ -60,7 +110,7 @@
60
110
  },
61
111
  placeholder: {
62
112
  type: String,
63
- default: "Type Here...",
113
+ default: "",
64
114
  },
65
115
  modelValue: {
66
116
  type: [String, Number],
@@ -78,68 +128,121 @@
78
128
  type: Boolean,
79
129
  default: false,
80
130
  },
131
+ maxLength: {
132
+ type: Number,
133
+ default: null,
134
+ },
135
+ letterSpacing: {
136
+ type: Number,
137
+ default: 0,
138
+ },
139
+ autocomplete: {
140
+ type: String,
141
+ default: "",
142
+ },
81
143
  customClass: {
82
144
  type: String,
83
145
  default: "",
84
146
  },
147
+ inputClass: {
148
+ type: [String, Array, Object],
149
+ default: "",
150
+ },
151
+ size: {
152
+ type: String,
153
+ default: "md",
154
+ validator: (value) => ["sm", "md", "lg"].includes(value),
155
+ },
85
156
  },
157
+ emits: ["update:modelValue", "focus", "blur"],
86
158
  data() {
87
159
  return {
88
160
  errorMessage: "",
89
161
  icon: "",
90
162
  iconClass: "text-current",
163
+ isFocused: false,
91
164
  };
92
165
  },
166
+ watch: {
167
+ modelValue: {
168
+ immediate: true,
169
+ handler(newValue) {
170
+ if (!this.isFocused && newValue?.length > 0) {
171
+ this.runValidation(newValue);
172
+ }
173
+ },
174
+ },
175
+ },
93
176
  computed: {
94
- inputClass() {
177
+ sizeConfig() {
178
+ return SIZE_CONFIG[this.size] || SIZE_CONFIG.md;
179
+ },
180
+ inputStateClass() {
95
181
  return {
96
- "border-outline-danger": this.errorMessage,
182
+ "border-outline-danger": this.errorMessage && !this.isFocused,
183
+ "border-outline-brand": this.isFocused,
97
184
  "border-transparent focus:border-outline-brand hover:border-outline-brand":
98
185
  !this.errorMessage,
99
186
  };
100
187
  },
101
- labelClass() {
102
- return {
103
- "peer-focus:top-[6px] peer-focus:left-2.5 peer-focus:text-sm peer-focus:font-bold": true,
104
- "top-[6px] left-2.5 text-sm font-bold":
105
- this.modelValue && this.modelValue.length > 0,
106
- };
188
+ labelFilledClass() {
189
+ return this.modelValue && this.modelValue.length > 0
190
+ ? this.sizeConfig.labelFilled
191
+ : "";
107
192
  },
108
193
  },
109
194
  methods: {
110
195
  handleInput(event) {
111
- this.$emit("update:modelValue", event.target.value);
196
+ const value = event.target.value;
197
+ this.$emit("update:modelValue", value);
198
+ },
199
+ handleFocus(event) {
200
+ this.isFocused = true;
201
+ this.iconClass = "";
202
+ this.icon = "";
203
+ this.$emit("focus", event);
112
204
  },
113
- handleBlur() {
205
+ handleBlur(event) {
206
+ this.isFocused = false;
114
207
  this.runValidation(this.modelValue);
208
+ this.$emit("blur", event);
115
209
  },
116
210
  runValidation(value) {
117
- for (let rule of this.rules) {
211
+ for (const rule of this.rules) {
118
212
  const error = rule(value);
119
213
  if (error) {
120
214
  this.errorMessage = error;
121
215
  this.iconClass = "text-content-danger";
122
216
  this.icon = "stroke-warning";
123
217
  return;
124
- } else {
125
- this.errorMessage = "";
126
- this.iconClass = "text-content-brand";
127
- this.icon = "bold-check";
128
218
  }
219
+ this.errorMessage = "";
220
+ this.iconClass = "text-content-brand";
221
+ this.icon = "bold-check";
129
222
  }
130
223
  },
224
+ validate() {
225
+ this.runValidation(this.modelValue);
226
+ },
131
227
  },
132
228
  };
133
229
  </script>
134
230
 
135
231
  <style scoped>
232
+ .inputgroup {
233
+ min-width: 100px;
234
+ }
235
+
136
236
  .icon-scale-enter-active,
137
237
  .icon-scale-leave-active {
138
238
  transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55);
139
239
  }
140
- .icon-scale-enter-from, .icon-scale-leave-to /* .icon-scale-leave-active in <2.1.8 */ {
141
- transform: scale(0);
240
+
241
+ .icon-scale-enter-from,
242
+ .icon-scale-leave-to {
243
+ transform: scale(0) translateX(100%);
142
244
  }
245
+
143
246
  .icon-scale-enter-to,
144
247
  .icon-scale-leave-from {
145
248
  transform: scale(1);
@@ -147,11 +250,12 @@
147
250
 
148
251
  span.iconwrapper {
149
252
  display: block;
150
- height: 2.25rem;
151
- width: 2.25rem;
152
- svg {
153
- width: 100%;
154
- height: 100%;
155
- }
253
+ height: 1.5rem;
254
+ width: 1.5rem;
255
+ }
256
+
257
+ span.iconwrapper svg {
258
+ width: 100%;
259
+ height: 100%;
156
260
  }
157
261
  </style>