diff --git a/packages/anu-vue/src/components/index.ts b/packages/anu-vue/src/components/index.ts index 42ac68c1..6aa26dd5 100644 --- a/packages/anu-vue/src/components/index.ts +++ b/packages/anu-vue/src/components/index.ts @@ -7,9 +7,9 @@ export { ABtn } from './btn' export { ACard } from './card' export { ACheckbox } from './checkbox' export { ADialog } from './dialog' - export { ADrawer } from './drawer' export { AInput } from './input' +export { AList } from './list' export { ARadio } from './radio' export { ASelect } from './select' export { ASwitch } from './switch' diff --git a/packages/anu-vue/src/components/list/AList.tsx b/packages/anu-vue/src/components/list/AList.tsx new file mode 100644 index 00000000..fd9f500b --- /dev/null +++ b/packages/anu-vue/src/components/list/AList.tsx @@ -0,0 +1,187 @@ +import type { PropType } from 'vue' +import { computed, defineComponent } from 'vue' +import { useGroupModel } from '../../composables' +import { ATypography } from '../typography' +import { useLayer, useProps as useLayerProps } from '@/composables/useLayer' + +import { AAvatar, isAvatarUsed } from '@/components/avatar' +import type { AvatarOnlyProps } from '@/components/avatar/props' + +// TODO: Reuse the existing props and its types. Maybe if we create AListItem component then we can reuse prop types. +interface ListItem extends AvatarOnlyProps { + title: string | string[] + subtitle?: string | string[] + text: string | string[] + src?: string + value?: any + disable?: boolean + $avatar?: { string: any } + + // color: 'primary' | 'success' | 'info' | 'warning' | 'danger' + // variant: 'fill' | 'outline' | 'light' | 'text' + // states?: boolean +} + +export const AList = defineComponent({ + name: 'AList', + props: { + ...useLayerProps({ + variant: { + default: 'text', + }, + states: { + default: true, + }, + }), + items: { + type: Array as PropType, + required: true, + }, + multi: { + type: Boolean, + default: false, + }, + modelValue: { + type: [String, Number, Object], + default: null, + }, + avatarAppend: { + type: Boolean, + default: false, + }, + }, + emits: ['update:modelValue'], + setup(props, { slots, emit }) { + const { getLayerClasses } = useLayer() + const { options, select: selectListItem, value } = useGroupModel({ + options: props.items[0].value ? props.items.map(i => i.value) : props.items.length, + multi: props.multi, + }) + const isAvatarPropsUsed = computed(() => { + return isAvatarUsed(props.items[0]) + }) + + // ℹī¸ [Configurable UI] This is helper function to extract the content & classes from prop/value + // TODO: We might need to move this to utils + const getArrayValue = computed(() => (val: string | string[] | Object[]) => { + const [content, classes] = val === undefined + ? [] + : typeof val === 'string' + ? [val] + : val + + return { content, classes } + }) + + // 👉 Icon renderer + const iconRenderer = (icon: string | string[] | Object[]) => { + const { content: iconClass, classes } = getArrayValue.value(icon) + + return + } + + // 👉 Avatar Renderer + const avatarRenderer = ( + content: typeof props.items[number]['content'], + src: typeof props.items[number]['src'], + alt: typeof props.items[number]['alt'], + icon: typeof props.items[number]['icon'], + $avatar: typeof props.items[number]['$avatar'], + ) => { + const _alt = alt || 'avatar' + + return + } + + const handleListItemClick = (index: number) => { + const itemValue = options.value[index].value + selectListItem(itemValue) + if (props.modelValue !== null) + emit('update:modelValue', value.value) + } + + // 👉 List items + const listItems = computed(() => props.items.map((listItem, itemIndex) => { + // ℹī¸ Reduce the size of title to 1rem. We did the same in ACard as well. + let titleProp: string[] | undefined + if (listItem.title) { + // if title property is string + if (typeof listItem.title === 'string') { + titleProp = [listItem.title, 'text-base'] + } + + // title property is array + else { + const [textContent, textClasses] = listItem.title + titleProp = [textContent, `${textClasses} uno-layer-base-text-sm`] + } + } + + const isActive = computed(() => options.value[itemIndex].isSelected) + + const layerProps = computed(() => { + return { + states: props.states, + color: isActive.value ? props.color || 'primary' : undefined, + variant: isActive.value ? props.variant || 'light' : 'text', + } + }) + + return
  • handleListItemClick(itemIndex)} + class={[ + 'a-list-item', + { 'a-layer-active': value.value === itemIndex }, + { 'opacity-50 pointer-events-none': listItem.disable }, + props.modelValue !== null + ? [...getLayerClasses(layerProps.value, { statesClass: 'states:10' }), 'cursor-pointer'] + : '', + 'flex items-center gap-$a-list-item-gap m-$a-list-item-margin p-$a-list-item-padding min-h-$a-list-item-min-height', + ]}> + { + slots.prepend + ? slots.prepend({ listItem, itemIndex }) + : isAvatarPropsUsed.value && !props.avatarAppend + ? avatarRenderer(listItem.content, listItem.src, listItem.alt, listItem.icon, listItem.$avatar) + : null + } + + { + slots.append + ? slots.append({ listItem, itemIndex }) + : isAvatarPropsUsed.value && props.avatarAppend + ? avatarRenderer(listItem.content, listItem.src, listItem.alt, listItem.icon, listItem.$avatar) + : null + } +
  • + })) + + // 👉 Return + return () => + }, +}) + +export type AList = InstanceType diff --git a/packages/anu-vue/src/components/list/index.ts b/packages/anu-vue/src/components/list/index.ts new file mode 100644 index 00000000..9d074e24 --- /dev/null +++ b/packages/anu-vue/src/components/list/index.ts @@ -0,0 +1,2 @@ +export { AList } from './AList' + diff --git a/packages/anu-vue/src/components/list/props.ts b/packages/anu-vue/src/components/list/props.ts new file mode 100644 index 00000000..fe35c48a --- /dev/null +++ b/packages/anu-vue/src/components/list/props.ts @@ -0,0 +1,8 @@ +import type { ComponentObjectPropsOptions } from 'vue' + +export const props: ComponentObjectPropsOptions = { + items: { + type: Array, + required: true, + }, +} as const diff --git a/packages/anu-vue/src/presets/theme-default/index.ts b/packages/anu-vue/src/presets/theme-default/index.ts index 27a62505..b968c7e9 100644 --- a/packages/anu-vue/src/presets/theme-default/index.ts +++ b/packages/anu-vue/src/presets/theme-default/index.ts @@ -6,112 +6,125 @@ interface PresetOptions { } export const colors = ['primary', 'success', 'info', 'warning', 'danger'] -const themeShortcuts: Exclude = { - // 👉 Grid - 'grid-row': 'grid gap-6 place-items-start w-full', - - // 👉 Typography - 'text-high-emphasis': 'text-[hsla(var(--a-base-color),var(--a-text-emphasis-high-opacity))]', - 'text-medium-emphasis': 'text-[hsla(var(--a-base-color),var(--a-text-emphasis-medium-opacity))]', - 'text-light-emphasis': 'text-[hsla(var(--a-base-color),var(--a-text-emphasis-light-opacity))]', - +const themeShortcuts: Exclude = [ // 👉 States - 'states': '\ - relative \ - before:content-empty \ - before:absolute \ - before:inset-0 \ - before:rounded-inherit \ - before:bg-current-color \ - before:opacity-0 \ - \ - before:transition \ - before:duration-200 \ - before:ease-in-out \ - \ - hover:before:opacity-15', - - // SECTION Components - // 👉 Alert - 'a-alert': 'p-4 font-medium rounded-lg gap-x-2', - - // 👉 Button - 'a-btn': 'px-[1em] font-medium rounded-[0.5em] gap-x-[0.5em] h-[2.5em]', - 'a-btn-icon-only': 'font-medium rounded-lg h-[2.5em] w-[2.5em] i:em:text-lg', - - // 👉 Base Input - 'a-base-input-root': 'min-w-[181px] gap-y-1', - 'a-base-input-input-container': 'i:em:w-6 i:em:h-6 gap-x-3', - 'a-base-input-input-wrapper': 'transition duration-250 ease-out flex i:em:w-5 i:em:h-5 gap-x-2 em:h-12 em:rounded-lg', - - 'a-base-input-prepend-inner-icon': 'ml-3 z-1', - 'a-base-input-append-inner-icon': 'em:me-3', - - 'a-base-input-w-prepend-inner': 'em:pl-10', - 'a-base-input-wo-prepend-inner': 'em:pl-4', - 'a-base-input-w-append-inner': 'em:pr-10', - 'a-base-input-wo-append-inner': 'em:pr-4', - - // ℹī¸ We have to add important before `bg-` because textarea has `bg-transparent` class - 'a-base-input-disabled': '!all-[.a-base-input-child]-bg-[hsla(var(--a-base-color),0.12)] opacity-50', - 'a-base-input-interactive': 'all-[.a-base-input-child]-placeholder:transition all-[.a-base-input-child]-placeholder:duration-250 all-[.a-base-input-child]-placeholder:ease all-[.a-base-input-child:focus]-placeholder-translate-x-1', - - // 👉 Card - 'a-card': 'rounded-lg shadow-lg', - 'a-card-typography-wrapper': 'card-padding next:pt-0 not-last:pb-4', - 'card-padding': 'p-5', - 'card-spacer': 'not-last-children-mb-$a-card-spacer', - 'card-body': 'card-padding', - - // 👉 Checkbox - 'a-checkbox-box': 'border-solid h-5 w-5 border-(2 a-border rounded) transition duration-200 mr-2', - 'a-checkbox-disabled': 'opacity-50', - 'a-checkbox-icon': 'transition duration-150 delay-100 ease-[cubic-bezier(.57,1.48,.87,1.09)]', - - // 👉 Dialog - 'a-dialog-wrapper': 'z-[51]', - 'a-dialog': 'shadow-2xl uno-layer-base-w-[500px] z-[52]', - - // 👉 Drawer - 'a-drawer-wrapper': 'z-[51]', - - // ℹī¸ We added `!rounded-none` because ACard have rounded utility that override the `rounded-none` - 'a-drawer': 'shadow-2xl uno-layer-base-w-[300px] z-[52] !rounded-none max-w-[calc(100vw-2rem)]', - - // 👉 Input - 'a-input-type-file': 'file:rounded-lg file:border-none file:mr-4 file:px-4 file:py-3 file:text-gray-500 file:rounded-r-none file:bg-[hsla(var(--a-base-color),0.05)] !px-0', - - // 👉 Radio - 'a-radio-circle': 'border-solid h-5 w-5 border-(2 a-border) rounded-full mr-2 p-1 after:(duration-250 ease-in-out)', // ℹī¸ :after is inner dot - 'a-radio-disabled': 'opacity-50', - - // 👉 Select - 'a-select-options-container': 'z-10 border border-solid border-a-border rounded-lg em:py-3 shadow-lg', - 'a-select-option': 'em:px-4 em:py-1', - - // 👉 Switch - 'a-switch-toggle': 'transition-colors transition-duration-100 ease-in-out', - 'a-switch-dot': 'h-[1.18em] w-[1.18em] bg-white transition transition-duration-200 ease-[cubic-bezier(0.16,1,0.3,1)]', - 'a-switch-icon': 'em:text-xs', - 'a-switch-disabled': 'opacity-50', - - // 👉 Table - 'a-table-table': 'all-[tr]-border-b all-[tr]-border-a-border', - 'a-table-table-th': 'capitalize em:px-[1.15rem] em:h-14 text-left', - 'a-table-table-td': 'em:px-[1.15rem] em:h-14', - 'a-table-footer': 'em:px-[1.15rem] em:h-14 gap-x-4', - 'a-table-footer-per-page-container': 'em:text-sm gap-x-2', - 'a-table-footer-per-page-select': 'text-xs w-16 min-w-auto', - 'a-table-footer-per-page-select--input-wrapper-classes': 'em:h-9 rounded-0 !border-transparent !border-b-a-border', // ℹī¸ inputWrapperClasses prop - 'a-table-footer-per-page-select--options-wrapper-classes': 'text-xs', // ℹī¸ optionsWrapperClasses prop - 'a-table-footer-previous-page-btn': '!rounded-full !text-xs me-2', - 'a-table-footer-next-page-btn': '!rounded-full !text-xs', - - // 👉 Textarea - 'a-textarea': 'py-4', + [/^states:?(\d+)?$/, ([, op]) => { + const stateSelector = `.states${op ? `\\:${op}` : ''}` + const _op = op || 15 + + return `\ + relative \ + before:content-empty \ + before:absolute \ + before:inset-0 \ + before:rounded-inherit \ + before:bg-current-color \ + before:opacity-0 \ + \ + before:transition \ + before:duration-200 \ + before:ease-in-out \ + \ + hover:before:opacity-${_op} \ + before:selector-[${stateSelector}.a-layer-active]-opacity-${_op}` + }], + { + // 👉 Grid + 'grid-row': 'grid gap-6 place-items-start w-full', + + // 👉 Typography + 'text-high-emphasis': 'text-[hsla(var(--a-base-color),var(--a-text-emphasis-high-opacity))]', + 'text-medium-emphasis': 'text-[hsla(var(--a-base-color),var(--a-text-emphasis-medium-opacity))]', + 'text-light-emphasis': 'text-[hsla(var(--a-base-color),var(--a-text-emphasis-light-opacity))]', + + // SECTION Components + // 👉 Alert + 'a-alert': 'p-4 font-medium rounded-lg gap-x-2', + + // 👉 Button + 'a-btn': 'px-[1em] font-medium rounded-[0.5em] gap-x-[0.5em] h-[2.5em]', + 'a-btn-icon-only': 'font-medium rounded-lg h-[2.5em] w-[2.5em] i:em:text-lg', + + // 👉 Base Input + 'a-base-input-root': 'min-w-[181px] gap-y-1', + 'a-base-input-input-container': 'i:em:w-6 i:em:h-6 gap-x-3', + 'a-base-input-input-wrapper': 'transition duration-250 ease-out flex i:em:w-5 i:em:h-5 gap-x-2 em:h-12 em:rounded-lg', + + 'a-base-input-prepend-inner-icon': 'ml-3 z-1', + 'a-base-input-append-inner-icon': 'em:me-3', + + 'a-base-input-w-prepend-inner': 'em:pl-10', + 'a-base-input-wo-prepend-inner': 'em:pl-4', + 'a-base-input-w-append-inner': 'em:pr-10', + 'a-base-input-wo-append-inner': 'em:pr-4', + + // ℹī¸ We have to add important before `bg-` because textarea has `bg-transparent` class + 'a-base-input-disabled': '!all-[.a-base-input-child]-bg-[hsla(var(--a-base-color),0.12)] opacity-50', + 'a-base-input-interactive': 'all-[.a-base-input-child]-placeholder:transition all-[.a-base-input-child]-placeholder:duration-250 all-[.a-base-input-child]-placeholder:ease all-[.a-base-input-child:focus]-placeholder-translate-x-1', + + // 👉 Card + 'a-card': 'rounded-lg shadow-lg', + 'a-card-typography-wrapper': 'card-padding next:pt-0 not-last:pb-4', + 'card-padding': 'p-5', + 'card-spacer': 'not-last-children-mb-$a-card-spacer', + 'card-body': 'card-padding', + + // 👉 Checkbox + 'a-checkbox-box': 'border-solid h-5 w-5 border-(2 a-border rounded) transition duration-200 mr-2', + 'a-checkbox-disabled': 'opacity-50', + 'a-checkbox-icon': 'transition duration-150 delay-100 ease-[cubic-bezier(.57,1.48,.87,1.09)]', + + // 👉 Dialog + 'a-dialog-wrapper': 'z-[51]', + 'a-dialog': 'shadow-2xl uno-layer-base-w-[500px] z-[52]', + + // 👉 Drawer + 'a-drawer-wrapper': 'z-[51]', + + // ℹī¸ We added `!rounded-none` because ACard have rounded utility that override the `rounded-none` + 'a-drawer': 'shadow-2xl uno-layer-base-w-[300px] z-[52] !rounded-none max-w-[calc(100vw-2rem)]', + + // 👉 Input + 'a-input-type-file': 'file:rounded-lg file:border-none file:mr-4 file:px-4 file:py-3 file:text-gray-500 file:rounded-r-none file:bg-[hsla(var(--a-base-color),0.05)] !px-0', + + // 👉 List + 'a-list': 'rounded-lg my-2', + + // 👉 Helper class to create pill shaped list items + 'a-list-items-pill': 'my-[0.65rem] children-[.a-list-item]-rounded-lg [--a-list-item-margin:0.18rem_0.75rem] [--a-list-item-padding:0.5rem_0.75rem]', + + // 👉 Radio + 'a-radio-circle': 'border-solid h-5 w-5 border-(2 a-border) rounded-full mr-2 p-1 after:(duration-250 ease-in-out)', // ℹī¸ :after is inner dot + 'a-radio-disabled': 'opacity-50', + + // 👉 Select + 'a-select-options-container': 'z-10 border border-solid border-a-border rounded-lg em:py-3 shadow-lg', + 'a-select-option': 'em:px-4 em:py-1', + + // 👉 Switch + 'a-switch-toggle': 'transition-colors transition-duration-100 ease-in-out', + 'a-switch-dot': 'h-[1.18em] w-[1.18em] bg-white transition transition-duration-200 ease-[cubic-bezier(0.16,1,0.3,1)]', + 'a-switch-icon': 'em:text-xs', + 'a-switch-disabled': 'opacity-50', + + // 👉 Table + 'a-table-table': 'all-[tr]-border-b all-[tr]-border-a-border', + 'a-table-table-th': 'capitalize em:px-[1.15rem] em:h-14 text-left', + 'a-table-table-td': 'em:px-[1.15rem] em:h-14', + 'a-table-footer': 'em:px-[1.15rem] em:h-14 gap-x-4', + 'a-table-footer-per-page-container': 'em:text-sm gap-x-2', + 'a-table-footer-per-page-select': 'text-xs w-16 min-w-auto', + 'a-table-footer-per-page-select--input-wrapper-classes': 'em:h-9 rounded-0 !border-transparent !border-b-a-border', // ℹī¸ inputWrapperClasses prop + 'a-table-footer-per-page-select--options-wrapper-classes': 'text-xs', // ℹī¸ optionsWrapperClasses prop + 'a-table-footer-previous-page-btn': '!rounded-full !text-xs me-2', + 'a-table-footer-next-page-btn': '!rounded-full !text-xs', + + // 👉 Textarea + 'a-textarea': 'py-4', // !SECTION Components -} + }, +] export function presetThemeDefault(options: PresetOptions = {}): Preset { const shortcuts = options.shortcutOverrides ? (defu(options.shortcutOverrides, themeShortcuts) as Preset['shortcuts']) : themeShortcuts diff --git a/packages/anu-vue/src/presets/theme-default/scss/index.scss b/packages/anu-vue/src/presets/theme-default/scss/index.scss index f70fceec..085e1dac 100644 --- a/packages/anu-vue/src/presets/theme-default/scss/index.scss +++ b/packages/anu-vue/src/presets/theme-default/scss/index.scss @@ -26,12 +26,12 @@ --a-typography-subtitle-opacity: var(--a-text-emphasis-light-opacity); --a-typography-text-opacity: var(--a-text-emphasis-medium-opacity); - // 👉 Components + // SECTION Components - // Card + // 👉 Card --a-card-spacer: 1rem; - // Switch + // 👉 Switch --a-switch-track-size: 3em; --a-switch-thumb-margin: .25em; --a-switch-default-color: 220, 13%, 91%; @@ -40,6 +40,16 @@ As this will be always on light background we will keep it static and choose color from light scheme */ --a-switch-icon-color: hsla(0, 10%, 20%, 0.68); + + // 👉 List + // ℹī¸ We might not need `--a-list-gap` this if we are using just `AList`, however when we use list inside another component this will be helpful + --a-list-gap: 0rem; + --a-list-item-gap: 0.75rem; + --a-list-item-padding: 0.5rem 1rem; + --a-list-item-margin: 0rem; + --a-list-item-min-height: 2.5rem; + + // !SECTION } :root.dark { @@ -53,9 +63,12 @@ --a-warning: 42.4, 73%, 50%; --a-danger: 358.3, 73%, 64.9%; - // 👉 Components - // Switch + // SECTION Components + + // 👉 Switch --a-switch-default-color: 0, 0%, 16%; + + // !SECTION } // 👉 Typography diff --git a/packages/documentation/docs/.vitepress/config.js b/packages/documentation/docs/.vitepress/config.js index 27ae5de4..f4f21f60 100644 --- a/packages/documentation/docs/.vitepress/config.js +++ b/packages/documentation/docs/.vitepress/config.js @@ -34,6 +34,7 @@ export default defineConfig({ { text: 'Dialog', link: '/guide/components/dialog' }, { text: 'Drawer', link: '/guide/components/drawer' }, { text: 'Input', link: '/guide/components/input' }, + { text: 'List', link: '/guide/components/list' }, { text: 'Radio', link: '/guide/components/radio' }, { text: 'Select', link: '/guide/components/select' }, { text: 'Switch', link: '/guide/components/switch' }, diff --git a/packages/documentation/docs/demos/list/DemoListAvatar.vue b/packages/documentation/docs/demos/list/DemoListAvatar.vue new file mode 100644 index 00000000..91eeb09c --- /dev/null +++ b/packages/documentation/docs/demos/list/DemoListAvatar.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/documentation/docs/demos/list/DemoListBasic.vue b/packages/documentation/docs/demos/list/DemoListBasic.vue new file mode 100644 index 00000000..be99d039 --- /dev/null +++ b/packages/documentation/docs/demos/list/DemoListBasic.vue @@ -0,0 +1,16 @@ + + + diff --git a/packages/documentation/docs/demos/list/DemoListSlots.vue b/packages/documentation/docs/demos/list/DemoListSlots.vue new file mode 100644 index 00000000..6b8bb654 --- /dev/null +++ b/packages/documentation/docs/demos/list/DemoListSlots.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/documentation/docs/demos/list/DemoListVModelSupport.vue b/packages/documentation/docs/demos/list/DemoListVModelSupport.vue new file mode 100644 index 00000000..bc95f533 --- /dev/null +++ b/packages/documentation/docs/demos/list/DemoListVModelSupport.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/documentation/docs/demos/list/DemoListVariants.vue b/packages/documentation/docs/demos/list/DemoListVariants.vue new file mode 100644 index 00000000..925c5afc --- /dev/null +++ b/packages/documentation/docs/demos/list/DemoListVariants.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/documentation/docs/guide/components/list.md b/packages/documentation/docs/guide/components/list.md new file mode 100644 index 00000000..6b55a344 --- /dev/null +++ b/packages/documentation/docs/guide/components/list.md @@ -0,0 +1,118 @@ +# List + + + + +## Basic + +`AList` is fully customizable and provides easy to use API to render list. It support `ATypography` props to render text quickly. + +You can also use `default` slot to render your custom content if you don't want to use typography props. + + + + + + + + + + +## Slots + +Use `before` & `after` slot to add custom content before and after list items. There's also `prepend` & `append` slot for list item to append & prepend custom content. + + + + + + + + + + +## Avatar + +You can also pass avatar props like `icon` to list item as property to render the desired avatar without writing extra markup. + +Moreover, to customize the avatar itself you can use `$avatar` property. E.g. Pass the `class` or another any prop/attribute. + + + + + + + +:::tip Taking flexibility to next level 🚀 +For maximum flexibility, `AList` also provides `avatar-append` boolean prop to tell `AList` to render the avatar at the end instead of at start. +::: + + + + +## `v-model` Support + +`AList` also support `v-model`. Use any value other than `null` to enable the `v-model` support. + +If you don't provide `value` property to each list item, `AList` will emit list item's index as selected value. + + + + + + + +:::tip +For selection, `AList` uses [`useGroupModel`](/guide/composables/useGroupModel). Hence, you can also use the `multi` prop to allow multiple selection. +::: + + + + +## Variants + +`AList` also accepts layer props like `variant`, `color` & `states` to change the appearance of list. + + + + + + + +:::tip +Use `a-list-items-pill` to create pill shaped list items. It just modifies some CSS to achieve the pill UI. +::: + +## CSS Variables + +`AList` comes with various CSS variables to customize the UI according to your need. You can check them in this [file](https://github.com/jd-solanki/anu/blob/main/packages/anu-vue/src/presets/theme-default/scss/index.scss).