Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Ladder): new component #976

Closed
wants to merge 10 commits into from
13 changes: 13 additions & 0 deletions docs/components/content/examples/LadderExampleStepObjects.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<ULadder :steps="steps" />
</template>

<script setup>
const steps = [
{ label: 'Address', icon: 'i-heroicons-home', color: 'red' },
{ label: 'Shipping', icon: 'i-heroicons-truck', inactiveColor: 'cyan' },
{ label: 'Trade-in', icon: 'i-heroicons-arrow-path', disabled: true },
{ label: 'Payment', icon: 'i-heroicons-banknotes', click: () => alert('You clicked Payment!') },
{ label: 'Checkout', icon: 'i-heroicons-check' }
]
</script>
124 changes: 124 additions & 0 deletions docs/content/2.elements/12.ladder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
---
description: 'Display a progress as a list of ordered steps.'
links:
- label: GitHub
icon: i-simple-icons-github
to: https://github.com/nuxt/ui/blob/dev/src/runtime/components/data/Table.vue
---

## Usage

Pass the step index as `v-model`, an array of steps as strings to the `steps` prop of the Ladder component.

::component-card
---
extraClass: overflow-x-auto
baseProps:
steps:
- Address
- Shipping
- Trade-in
- Payment
- Checkout
props:
modelValue: 3
options:
- name: modelValue
restriction: only
values:
- 0
- 1
- 2
- 3
- 4
---
::

You may also pass an array of objects with following properties:

- `label` - The label of the step.
- `icon` - The icon of the step.
- `disabled` - Whether the step is disabled.
- `color` - The active color of the step.
- `inactiveColor` - The inactive color of the step.
- `click` - The click handler of the step.
- `to` - Execute navigation to the set destination.

:component-example{component="ladder-example-step-objects" extraClass="overflow-x-auto"}

::callout{icon="i-heroicons-exclamation-triangle"}
The `click` handler will take precedence over the navigation handler. If `click` is set to `false`, the navigation handler is disabled, even if `to` is declared.
::

## Click

Steps don't change when the user clicks on it. To enable this behaviour, you can either set the `click` prop to `true`, or on each step setting `click` to `true`.

::component-card
---
extraClass: overflow-x-auto
baseProps:
modelValue: 0
steps:
- Address
- Shipping
- Trade-in
- Payment
- Checkout
props:
click: true
---
::

## Separators

The steps are separated with horizontal lines that can be disabled by setting `separators` to `false`.

::component-card
---
extraClass: overflow-x-auto
baseProps:
steps:
- Address
- Shipping
- Trade-in
- Payment
- Checkout
props:
separators: false
---
::

## Style

Steps can be either active or inactive. You may change the color of each state using the corresponding prop:

- `color` for an active step
- `inactiveColor` for an inactive step

Besides all the colors from the `ui.colors` object, you can also use the `gray`.

::component-card
---
extraClass: overflow-x-auto
baseProps:
steps:
- Address
- Shipping
- Trade-in
- Payment
- Checkout
click: true
props:
color: red
inactiveColor: blue
---
::

## Props

:component-props

## Config

:component-preset
6 changes: 6 additions & 0 deletions src/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ const safelistByComponent = {
}, {
pattern: new RegExp(`text-(${colorsAsRegex})-500`)
}],
ladder: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-200`)
}, {
pattern: new RegExp(`bg-(${colorsAsRegex})-700`),
variants: ['dark']
}],
meter: (colorsAsRegex) => [{
pattern: new RegExp(`bg-(${colorsAsRegex})-400`),
variants: ['dark']
Expand Down
185 changes: 185 additions & 0 deletions src/runtime/components/elements/Ladder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<template>
<ol :class="ui.base" v-bind="attrs">
<template v-for="(wrapper, key) in computedSteps" :key="key">
<li :class="ui.container">
<div :class="ui.step">
<div :class="ui.indicator">
<div v-if="hasSeparators" :class="ui.separator.wrapper">
<div :class="[ui.separator.base, ui.separator.color, key < 1 ? 'invisible' : '']" />
<div :class="[ui.separator.base, ui.separator.color, key > computedSteps.length - 2 ? 'invisible' : '']" class="ml-10" />
</div>
<div :class="ui.icon.wrapper">
<component
:is="wrapper.step.click || wrapper.step.to ? 'UButton' : 'div'"
:class="[ui.icon.base, ui.icon.rounded, ui.icon.shadow, ui.icon.size, ui.icon.ring, getIconClass(wrapper)]"
type="button"
variant="ghost"
v-bind="omit(wrapper.step, ['label', 'click', 'icon'])"
@click="wrapper.step.click"
>
<UIcon v-if="wrapper.step.icon" :name="wrapper.step.icon" />
<div v-else>
{{ key + 1 }}
</div>
</component>
</div>
</div>

<div :class="[ui.label.base, ui.label.size, getLabelClass(wrapper)]">
{{ wrapper.step.label }}
</div>
</div>
</li>
</template>
</ol>
</template>

<script lang="ts">
import { computed, defineComponent, toRef } from 'vue'
import type { ComputedRef, PropType } from 'vue'
import { twJoin } from 'tailwind-merge'
import { useUI } from '../../composables/useUI'
import { mergeConfig, omit } from '../../utils'
import type { Strategy, LadderSize, LadderStep, LadderStepWrapper, LadderStepColor } from '../../types'

Check warning on line 43 in src/runtime/components/elements/Ladder.vue

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest, 18)

'LadderSize' is defined but never used
// @ts-expect-error
import appConfig from '#build/app.config'
import { ladder } from '#ui/ui.config'
import UButton from './Link.vue'

const config = mergeConfig<typeof ladder>(appConfig.ui.strategy, appConfig.ui.ladder, ladder)

export default defineComponent({
components: {
UButton
},
inheritAttrs: false,
props: {
modelValue: {
type: Number,
default: 0
},
steps: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can call this items like in Feed?

type: Array as PropType<String[] | LadderStep[]>,
default: () => []
},
separators: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be nice to call this divider for consistency.

type: Boolean,
default: true
},
color: {
type: String as PropType<LadderStepColor>,
default: () => config.default.color,
validator (value: string) {
return [...appConfig.ui.colors, 'gray'].includes(value)
}
},
inactiveColor: {
type: String as PropType<LadderStepColor>,
default: () => config.default.inactiveColor,
validator (value: string) {
return [...appConfig.ui.colors, 'gray'].includes(value)
}
},
click: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be handled in items directly and emit a click instead of using a prop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You know? You're right if you think that the normal implementation for this would be to not have any click behavior and let the changes be done outside, like a form.

I just thought that it would be faster to implement a way to let the dev handle the click for changing steps in one variable rather than adding the handler on each item.

Can I set then the change-on-click prop to add a default click handler to all items to change to that step when clicked?

type: Boolean,
default: false
},
class: {
type: [String, Object, Array] as PropType<any>,
default: undefined
},
ui: {
type: Object as PropType<Partial<typeof config & { strategy?: Strategy }>>,
default: undefined
}
},
emits: ['update:modelValue'],
setup (props, { emit }) {
const { ui, attrs } = useUI('ladder', toRef(props, 'ui'), config, toRef(props, 'class'))

const hasSeparators = computed(() => props.separators)

function getIconClass (wrapper: LadderStepWrapper) {
const color = {
key: 'inactive',
name: wrapper.step.inactiveColor ?? props.inactiveColor
}

if (wrapper.isActive) {
color.key = 'active'
color.name = wrapper.step.color ?? props.color
}

return twJoin(
ui.value.icon[color.key].background.replaceAll('{color}', color.name),
ui.value.icon[color.key].ring.replaceAll('{color}', color.name),
ui.value.icon[color.key].color.replaceAll('{color}', color.name),
ui.value.icon[color.key].shadow
)
}

function getLabelClass (wrapper: LadderStepWrapper) {
const color = {
key: 'inactive',
name: wrapper.step.inactiveColor ?? props.inactiveColor
}

if (wrapper.isActive) {
color.key = 'active'
color.name = wrapper.step.color ?? props.color
}

return twJoin(
ui.value.label[color.key].replaceAll('{color}', color.name)
)
}

const ladderStepActiveIconClass = computed(() => {
return twJoin(
ui.value.icon.active.background.replaceAll('{color}', props.color),
ui.value.icon.active.ring.replaceAll('{color}', props.color),
ui.value.icon.active.color.replaceAll('{color}', props.color),
ui.value.icon.active.shadow
)
})

const ladderStepInactiveIconClass = computed(() => {
return twJoin(
ui.value.icon.inactive.background.replaceAll('{color}', props.inactiveColor),
ui.value.icon.inactive.ring.replaceAll('{color}', props.inactiveColor),
ui.value.icon.inactive.color.replaceAll('{color}', props.inactiveColor),
ui.value.icon.inactive.shadow
)
})

const computedSteps: ComputedRef<LadderStepWrapper[]> = computed(() => {
return props.steps.map((receivedStep, index: number) => {
const step = typeof receivedStep === 'string' ? { label: receivedStep } : receivedStep

const isActive = index === props.modelValue

if (isActive) {
step.click = false
} else if (props.click || step.click === true) {
step.click = () => emit('update:modelValue', index)
}

return { step, isActive }
})
})

return {
attrs,
// eslint-disable-next-line vue/no-dupe-keys
ui,
hasSeparators,
getIconClass,
getLabelClass,
ladderStepActiveIconClass,
ladderStepInactiveIconClass,
computedSteps,
omit
}
}
})
</script>
1 change: 1 addition & 0 deletions src/runtime/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './command-palette'
export * from './dropdown'
export * from './form-group'
export * from './form'
export * from './ladder'
export * from './input'
export * from './kbd'
export * from './link'
Expand Down
21 changes: 21 additions & 0 deletions src/runtime/types/ladder.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { RouteParams } from 'vue-router'
import { ladder } from '../ui.config'
import colors from '#ui-colors'

export type LadderSize = keyof typeof ladder.icon.size
export type LadderStepColor = 'gray' | typeof colors[number]

export interface LadderStep {
label: string,
to?: RouteParams | string,
click?: Function | boolean,
icon?: string,
disabled?: boolean,
color?: LadderStepColor,
inactiveColor?: LadderStepColor
}

export interface LadderStepWrapper {
step: LadderStep,
isActive: boolean,
}
Loading