Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,24 @@ jobs:
run_install: true

- name: Build
working-directory: packages/former/
run: pnpm build

- name: Set version
- name: Set version @former-ui/former
working-directory: packages/former/
run: pnpm version ${GITHUB_REF#refs/*/} --no-commit-hooks --no-git-tag-version
if: 'github.ref_type == ''tag'''

- name: Release
- name: Release @former-ui/former
working-directory: packages/former/
run: pnpm publish --no-git-check --access public
if: 'github.ref_type == ''tag'''

- name: Set version @former-ui/preset-nuxt-ui
working-directory: packages/preset-nuxt-ui/
run: pnpm version ${GITHUB_REF#refs/*/} --no-commit-hooks --no-git-tag-version
if: 'github.ref_type == ''tag'''

- name: Release @former-ui/preset-nuxt-ui
working-directory: packages/preset-nuxt-ui/
run: pnpm publish --no-git-check --access public
if: 'github.ref_type == ''tag'''
32 changes: 22 additions & 10 deletions packages/former/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,31 @@ export default {

This way, Tailwind will generate only the classes used by both your app and Former UI.

### Dark Mode Configuration
#### Tailwind v4 (CSS-first)

**Important:** Former UI uses Tailwind's `selector`-based dark mode. If you're using Option 2 (building your own Tailwind CSS), you **must** configure your Tailwind config to use `selector`-based dark mode:
If your app uses Tailwind v4’s CSS entry (for example `@import "tailwindcss"` in your main stylesheet), add Former as a **`@source`** so utilities referenced in Former’s components are generated. Paths are relative to that CSS file.

```js
// tailwind.config.js
export default {
// ... other config
darkMode: 'selector', // Required for Former UI dark mode to work correctly
};
By default, Tailwind v4’s `dark` variant follows **`prefers-color-scheme`**. Former’s components expect **`dark:`** utilities to apply when a **`dark` class** sits on an ancestor (same idea as `darkMode: 'selector'` in v3). Override the variant if you toggle dark mode that way:

```css
@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));

/* Use `src` when linking the package in a monorepo; use `dist` if that is all your install contains. */
@source '../node_modules/former-ui/src';
```

This means dark mode is activated by adding a `dark` class to a parent element (typically `<html>` or a wrapper element), rather than using the system preference. If you're using Option 1 (importing the pre-built CSS), the dark mode classes are already included, but you still need to ensure your app's Tailwind config uses `darkMode: 'selector'` if you're also generating your own Tailwind CSS.
You do **not** need `import 'former-ui/former-ui.css'` when Former is covered by your Tailwind build this way.

### Dark Mode Configuration

**Important:** Former UI uses Tailwind’s **selector**-based dark mode (`dark` class on an ancestor), not only `prefers-color-scheme`.

- **Tailwind v3 (`tailwind.config.js`):** set `darkMode: 'selector'`.
- **Tailwind v4 (CSS-first):** define a class-based `dark` variant as in the snippet above (for example `@custom-variant dark (&:where(.dark, .dark *));`). If another part of your stack also toggles a `dark` class on `<html>` from system settings, align that behavior with how you want Former to behave.

If you’re using Option 1 (importing the pre-built CSS), dark utilities are already in that bundle; you still need selector-style `dark` on a parent if you toggle dark mode with a class (same as Option 2).

## Usage

Expand Down Expand Up @@ -123,7 +135,7 @@ Just configure your form layout, define the components and let former do the res
</template>

<script setup lang="ts">
import { FormAdd, type FormComponents, type FormData, Former, FormNodeProps, Mode, type SchemaNode } from 'former-ui';
import { FormAdd, type FormComponents, type FormData, Former, FormNodeProps, Mode, type SchemaNode } from '@former-ui/former';
import { markRaw, ref } from 'vue';

import TextInput from './TextInput.vue';
Expand Down
1 change: 0 additions & 1 deletion packages/former/env.d.ts

This file was deleted.

4 changes: 2 additions & 2 deletions packages/former/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "former-ui",
"name": "@former-ui/former",
"type": "module",
"version": "0.0.0",
"license": "MIT",
Expand Down Expand Up @@ -58,7 +58,7 @@
"vite": "6.4.2",
"vite-plugin-dts": "4.3.0",
"vitest": "4.1.5",
"vue": "3.5.13",
"vue": "3.5.21",
"vue-tsc": "3.0.7"
}
}
12 changes: 6 additions & 6 deletions packages/former/src/components/FormAdd.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FormComponents, Mode } from '~/types';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref, unref } from 'vue';
import { defineComponent, h, markRaw, nextTick, ref, type Ref, unref } from 'vue';
import { inject } from '~/compositions/injectProvide';
import * as utils from '~/utils';

Expand All @@ -26,24 +26,24 @@ const FieldStub = defineComponent({

const InternalStub = defineComponent({ name: 'InternalStub', template: '<div data-internal-never />' });

const ADD_COMPONENTS: FormComponents = {
const ADD_COMPONENTS: Ref<FormComponents> = ref({
text: {
label: 'Text field',
propsSchema: [],
component: FieldStub,
component: markRaw(FieldStub),
},
number: {
label: 'Number field',
propsSchema: [],
component: FieldStub,
component: markRaw(FieldStub),
},
hiddenInternal: {
label: 'Should not list',
propsSchema: [],
component: InternalStub,
component: markRaw(InternalStub),
internal: true,
},
};
});

const SlotInjectProbe = defineComponent({
name: 'SlotInjectProbe',
Expand Down
4 changes: 2 additions & 2 deletions packages/former/src/components/FormAdd.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { inject, provide } from '~/compositions/injectProvide';
import { setDragEventData } from '~/utils';

Expand All @@ -40,5 +40,5 @@ function startDrag(e: DragEvent, nodeType: string) {
setDragEventData(e, formId.value, 'new_node_type', nodeType);
}

const relevantComponents = Object.entries(components).filter(([, { internal }]) => !internal);
const relevantComponents = computed(() => Object.entries(components.value).filter(([, { internal }]) => !internal));
</script>
12 changes: 6 additions & 6 deletions packages/former/src/components/FormNode.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FormComponents, InternalSchemaNode, Validator } from '~/types';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, inject, nextTick, ref, type Ref } from 'vue';
import { defineComponent, h, inject, markRaw, nextTick, ref, type Ref } from 'vue';
import * as utils from '~/utils';

import FormNode from './FormNode.vue';
Expand Down Expand Up @@ -37,13 +37,13 @@ const SlotProbe = defineComponent({
},
});

const TEXT_COMPONENTS: FormComponents = {
const TEXT_COMPONENTS: Ref<FormComponents> = ref({
text: {
label: 'Text',
propsSchema: [{ type: 'text', name: '$name' }],
component: DynamicComponent,
component: markRaw(DynamicComponent),
},
};
});

describe('component FormNode', () => {
let setDragEventDataSpy: ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -176,13 +176,13 @@ describe('component FormNode', () => {
const selectedNode = ref<InternalSchemaNode | undefined>(undefined);
const formId = ref('form-123');
const validator: Validator = () => true;
const componentsNoView: FormComponents = {
const componentsNoView: Ref<FormComponents> = ref({
text: {
label: 'Text',
propsSchema: [{ type: 'text', name: '$name' }],
component: undefined as unknown as (typeof DynamicComponent),
},
};
});
const wrapper = mount(FormNode, {
props: { node },
global: {
Expand Down
2 changes: 1 addition & 1 deletion packages/former/src/components/FormNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const components = inject('components');

const node = toRef(props, 'node');
const { component, error, isShown, modelValue } = useNode(node);
const isNodeValidFlag = computed(() => isNodeValid(node.value, validator, components));
const isNodeValidFlag = computed(() => isNodeValid(node.value, validator, components.value));

const mode = inject('mode');
const selectedNode = inject('selectedNode');
Expand Down
14 changes: 7 additions & 7 deletions packages/former/src/components/FormNodeProps.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { FormComponents, InternalSchemaNode, Validator } from '~/types';
import { mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { defineComponent, h, nextTick, ref, type Ref } from 'vue';
import * as utils from '~/utils';

import Former from './Former.vue';
import FormNodeProps from './FormNodeProps.vue';

const FIELD_DUMMY = defineComponent({ name: 'FieldDummy', template: '<span />' });

const MINIMAL_COMPONENTS: FormComponents = {
const MINIMAL_COMPONENTS: Ref<FormComponents> = ref({
text: {
label: 'Text',
propsSchema: [{ type: 'text', name: 'title' }],
Expand All @@ -20,7 +20,7 @@ const MINIMAL_COMPONENTS: FormComponents = {
propsSchema: [{ type: 'text', name: 'value' }],
component: FIELD_DUMMY,
},
};
});

describe('component FormNodeProps', () => {
let deleteNodeSpy: ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -162,7 +162,7 @@ describe('component FormNodeProps', () => {
global: {
renderStubDefaultSlot: true,
provide: {
components: {},
components: ref({}),
schema,
validator,
selectedNode,
Expand Down Expand Up @@ -254,11 +254,11 @@ describe('component FormNodeProps', () => {
},
},
});
expect(toInternalSchemaSpy).toHaveBeenCalledWith(components.text.propsSchema);
expect(toInternalSchemaSpy).toHaveBeenCalledWith(components.value.text.propsSchema);
toInternalSchemaSpy.mockClear();
selectedNode.value = { _id: 'n2', type: 'number', name: 'n', props: {} };
await nextTick();
expect(toInternalSchemaSpy).toHaveBeenCalledWith(components.number.propsSchema);
expect(toInternalSchemaSpy).toHaveBeenCalledWith(components.value.number.propsSchema);
});

it('renders the Former stub when propsSchema resolves', () => {
Expand Down Expand Up @@ -301,7 +301,7 @@ describe('component FormNodeProps', () => {
global: {
renderStubDefaultSlot: true,
provide: {
components: {},
components: ref({}),
schema,
validator,
selectedNode,
Expand Down
2 changes: 1 addition & 1 deletion packages/former/src/components/FormNodeProps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const validator = inject('validator');

const selectedNode = inject('selectedNode');

const selectedNodeType = computed(() => (selectedNode.value ? components[selectedNode.value.type] : undefined));
const selectedNodeType = computed(() => (selectedNode.value ? components.value[selectedNode.value.type] : undefined));

const selectedNodePropsSchema = ref<InternalSchemaNode[]>();

Expand Down
25 changes: 13 additions & 12 deletions packages/former/src/components/Former.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Ref } from 'vue';
import type { FieldData, FormComponents, FormData, InternalSchemaNode, InternalShowIfPredicate, Mode, SchemaNode, ShowIfPredicate, Texts, Validator } from '~/types';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, onBeforeUnmount } from 'vue';
import { defineComponent, h, markRaw, nextTick, onBeforeUnmount } from 'vue';
import { inject as formerInject } from '~/compositions/injectProvide';
import { toInternalSchema } from '~/utils';

Expand All @@ -25,20 +26,20 @@ const COMPONENTS_FIXTURE: FormComponents = {
text: {
label: 'Text',
propsSchema: [{ type: 'text', name: '$name' }],
component: DynamicComponent,
component: markRaw(DynamicComponent),
},
};

type FormerProbeBundle = {
schema: import('vue').Ref<InternalSchemaNode[]>;
data: import('vue').Ref<FormData | FieldData>;
rootSchema: import('vue').Ref<SchemaNode[]>;
validityMap: import('vue').Ref<Record<string, boolean | undefined>>;
selectedNode: import('vue').Ref<InternalSchemaNode | undefined>;
formerIsUpdating: import('vue').Ref<boolean>;
mode: import('vue').Ref<Mode>;
components: FormComponents;
texts: import('vue').Ref<Texts>;
schema: Ref<InternalSchemaNode[]>;
data: Ref<FormData | FieldData>;
rootSchema: Ref<SchemaNode[]>;
validityMap: Ref<Record<string, boolean | undefined>>;
selectedNode: Ref<InternalSchemaNode | undefined>;
formerIsUpdating: Ref<boolean>;
mode: Ref<Mode>;
components: Ref<FormComponents>;
texts: Ref<Texts>;
showIf: InternalShowIfPredicate | undefined;
};

Expand Down Expand Up @@ -174,7 +175,7 @@ describe('component Former', () => {
expect(activeFormerProbe).not.toBeNull();
const probe = activeFormerProbe!;
expect(probe.mode.value).toBe('build');
expect(probe.components).toEqual(COMPONENTS_FIXTURE);
expect(probe.components.value).toEqual(COMPONENTS_FIXTURE);
expect(probe.formerIsUpdating.value).toBe(true);
expect(probe.texts.value.dragHint).toBe('Custom hint');
});
Expand Down
2 changes: 1 addition & 1 deletion packages/former/src/components/Former.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ provide('rootData', wrappedData);
provide('rootSchema', schema);

provide('mode', toRef(props, 'mode'));
provide('components', props.components);
provide('components', toRef(props, 'components'));

provide('showIf', (node: SchemaNode) => {
if (!props.showIf) {
Expand Down
2 changes: 1 addition & 1 deletion packages/former/src/compositions/injectProvide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type InjectKeys = {
formerIsUpdating: Ref<boolean>;
schema: Ref<InternalSchemaNode[]>;
data: Ref<FormData | FieldData>;
components: FormComponents;
components: Ref<FormComponents>;
selectedNode: Ref<InternalSchemaNode | undefined>;
validityMap: Ref<Record<string, boolean | undefined>>;
showIf?: InternalShowIfPredicate;
Expand Down
10 changes: 5 additions & 5 deletions packages/former/src/compositions/useNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type MountUseNodeOptions = {
node: Ref<InternalSchemaNode>;
data?: Ref<FormData | unknown>;
validityMap?: Ref<Record<string, boolean | undefined>>;
components?: FormComponents;
components?: Ref<FormComponents>;
validator?: Validator;
/** Omit key so `showIf` is not provided. Use `showIf: undefined` to call `provide('showIf', undefined)`. */
showIf?: ((node: InternalSchemaNode) => boolean) | undefined;
Expand All @@ -38,7 +38,7 @@ type MountUseNodeOptions = {
function mountUseNode(options: MountUseNodeOptions) {
const data = options.data ?? ref<FormData>({});
const validityMap = options.validityMap ?? ref<Record<string, boolean | undefined>>({});
const components = options.components ?? defaultComponents();
const components = options.components ?? ref(defaultComponents());
const validator = options.validator ?? vi.fn<Validator>(() => true);

const ValidityInjector = defineComponent({
Expand Down Expand Up @@ -102,19 +102,19 @@ describe('useNode', () => {
it('resolves component from components[node.type].component', () => {
const node = ref<InternalSchemaNode>({ _id: 'n1', type: FIELD_TYPE, name: 'a' });
const { component } = mountUseNode({ node });
expect(component.value).toBe(StubField);
expect(component.value).toStrictEqual(StubField);
});

it('treats the node as layout when no propsSchema entry has name $name', () => {
const node = ref<InternalSchemaNode>({ _id: 'n1', type: LAYOUT_TYPE });
const { isLayoutComponent } = mountUseNode({ node });
expect(isLayoutComponent.value).toBe(true);
expect(isLayoutComponent.value).toStrictEqual(true);
});

it('treats the node as a field when some propsSchema has name $name', () => {
const node = ref<InternalSchemaNode>({ _id: 'n1', type: FIELD_TYPE, name: 'x' });
const { isLayoutComponent } = mountUseNode({ node });
expect(isLayoutComponent.value).toBe(false);
expect(isLayoutComponent.value).toStrictEqual(false);
});
});

Expand Down
6 changes: 3 additions & 3 deletions packages/former/src/compositions/useNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export default function useNode(node: Ref<InternalSchemaNode>) {
const data = inject('data');
const components = inject('components');

const component = computed(() => components[node.value.type]?.component);
const isLayoutComponent = computed(() => isNodeLayoutComponent(node.value, components));
const component = computed(() => components.value[node.value.type]?.component);
const isLayoutComponent = computed(() => isNodeLayoutComponent(node.value, components.value));

const modelValue = computed({
get() {
Expand Down Expand Up @@ -78,7 +78,7 @@ export default function useNode(node: Ref<InternalSchemaNode>) {

watch(isShown, () => {
if (!isShown.value) {
unsetDataOfNode(node.value, data.value, components);
unsetDataOfNode(node.value, data.value, components.value);
delete validityMap.value[node.value._id];
}
else {
Expand Down
Loading
Loading